Repository: HMCL-dev/HMCL Branch: main Commit: 87010309343a Files: 1060 Total size: 9.3 MB Directory structure: gitextract_ivylyroq/ ├── .cnb/ │ └── ISSUE_TEMPLATE/ │ └── config.yml ├── .editorconfig ├── .gitee/ │ └── ISSUE_TEMPLATE/ │ └── config.yml ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ ├── config.yml │ │ └── feature.yml │ └── workflows/ │ ├── check-codes.yml │ ├── gradle.yml │ ├── mirror.yml │ └── release.yml ├── .gitignore ├── HMCL/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── src/ │ │ ├── main/ │ │ │ ├── java/ │ │ │ │ ├── com/ │ │ │ │ │ └── jfoenix/ │ │ │ │ │ ├── controls/ │ │ │ │ │ │ ├── JFXButton.java │ │ │ │ │ │ ├── JFXCheckBox.java │ │ │ │ │ │ ├── JFXClippedPane.java │ │ │ │ │ │ ├── JFXColorPicker.java │ │ │ │ │ │ ├── JFXComboBox.java │ │ │ │ │ │ ├── JFXDialog.java │ │ │ │ │ │ ├── JFXDialogLayout.java │ │ │ │ │ │ ├── JFXListCell.java │ │ │ │ │ │ ├── JFXListView.java │ │ │ │ │ │ ├── JFXPasswordField.java │ │ │ │ │ │ ├── JFXPopup.java │ │ │ │ │ │ ├── JFXProgressBar.java │ │ │ │ │ │ ├── JFXRadioButton.java │ │ │ │ │ │ ├── JFXRippler.java │ │ │ │ │ │ ├── JFXSlider.java │ │ │ │ │ │ ├── JFXSnackbar.java │ │ │ │ │ │ ├── JFXSnackbarLayout.java │ │ │ │ │ │ ├── JFXSpinner.java │ │ │ │ │ │ ├── JFXTextArea.java │ │ │ │ │ │ ├── JFXTextField.java │ │ │ │ │ │ ├── JFXToggleButton.java │ │ │ │ │ │ ├── JFXTreeCell.java │ │ │ │ │ │ ├── JFXTreeView.java │ │ │ │ │ │ ├── behavior/ │ │ │ │ │ │ │ └── JFXGenericPickerBehavior.java │ │ │ │ │ │ ├── datamodels/ │ │ │ │ │ │ │ └── treetable/ │ │ │ │ │ │ │ └── RecursiveTreeObject.java │ │ │ │ │ │ └── events/ │ │ │ │ │ │ └── JFXDialogEvent.java │ │ │ │ │ ├── effects/ │ │ │ │ │ │ └── JFXDepthManager.java │ │ │ │ │ ├── skins/ │ │ │ │ │ │ ├── JFXButtonSkin.java │ │ │ │ │ │ ├── JFXCheckBoxSkin.java │ │ │ │ │ │ ├── JFXColorPalette.java │ │ │ │ │ │ ├── JFXColorPickerSkin.java │ │ │ │ │ │ ├── JFXColorPickerUI.java │ │ │ │ │ │ ├── JFXCustomColorPicker.java │ │ │ │ │ │ ├── JFXCustomColorPickerDialog.java │ │ │ │ │ │ ├── JFXGenericPickerSkin.java │ │ │ │ │ │ ├── JFXListViewSkin.java │ │ │ │ │ │ ├── JFXPopupSkin.java │ │ │ │ │ │ ├── JFXProgressBarSkin.java │ │ │ │ │ │ ├── JFXRadioButtonSkin.java │ │ │ │ │ │ ├── JFXSliderSkin.java │ │ │ │ │ │ ├── JFXSpinnerSkin.java │ │ │ │ │ │ ├── JFXTabPaneSkin.java │ │ │ │ │ │ └── JFXToggleButtonSkin.java │ │ │ │ │ ├── transitions/ │ │ │ │ │ │ ├── CacheMemento.java │ │ │ │ │ │ ├── JFXAnimationTimer.java │ │ │ │ │ │ ├── JFXKeyFrame.java │ │ │ │ │ │ └── JFXKeyValue.java │ │ │ │ │ └── utils/ │ │ │ │ │ ├── JFXNodeUtils.java │ │ │ │ │ └── TreeShowingProperty.java │ │ │ │ └── org/ │ │ │ │ └── jackhuang/ │ │ │ │ └── hmcl/ │ │ │ │ ├── EntryPoint.java │ │ │ │ ├── Launcher.java │ │ │ │ ├── Metadata.java │ │ │ │ ├── countly/ │ │ │ │ │ └── CrashReport.java │ │ │ │ ├── game/ │ │ │ │ │ ├── HMCLCacheRepository.java │ │ │ │ │ ├── HMCLGameLauncher.java │ │ │ │ │ ├── HMCLGameRepository.java │ │ │ │ │ ├── HMCLModpackInstallTask.java │ │ │ │ │ ├── HMCLModpackManifest.java │ │ │ │ │ ├── HMCLModpackProvider.java │ │ │ │ │ ├── LauncherHelper.java │ │ │ │ │ ├── LocalizedRemoteModRepository.java │ │ │ │ │ ├── Log.java │ │ │ │ │ ├── LogExporter.java │ │ │ │ │ ├── ManuallyCreatedModpackException.java │ │ │ │ │ ├── ManuallyCreatedModpackInstallTask.java │ │ │ │ │ ├── ModpackHelper.java │ │ │ │ │ ├── OAuthServer.java │ │ │ │ │ └── TexturesLoader.java │ │ │ │ ├── java/ │ │ │ │ │ ├── HMCLJavaRepository.java │ │ │ │ │ ├── JavaInfoUtils.java │ │ │ │ │ ├── JavaInstallTask.java │ │ │ │ │ ├── JavaLocalFiles.java │ │ │ │ │ ├── JavaManager.java │ │ │ │ │ └── JavaManifest.java │ │ │ │ ├── setting/ │ │ │ │ │ ├── Accounts.java │ │ │ │ │ ├── AuthlibInjectorServers.java │ │ │ │ │ ├── Config.java │ │ │ │ │ ├── ConfigHolder.java │ │ │ │ │ ├── ConfigUpgrader.java │ │ │ │ │ ├── DownloadProviders.java │ │ │ │ │ ├── EnumBackgroundImage.java │ │ │ │ │ ├── EnumCommonDirectory.java │ │ │ │ │ ├── FontManager.java │ │ │ │ │ ├── GlobalConfig.java │ │ │ │ │ ├── JavaVersionType.java │ │ │ │ │ ├── LauncherVisibility.java │ │ │ │ │ ├── Profile.java │ │ │ │ │ ├── Profiles.java │ │ │ │ │ ├── ProxyManager.java │ │ │ │ │ ├── SambaException.java │ │ │ │ │ ├── Settings.java │ │ │ │ │ ├── StyleSheets.java │ │ │ │ │ ├── VersionIconType.java │ │ │ │ │ └── VersionSetting.java │ │ │ │ ├── terracotta/ │ │ │ │ │ ├── TerracottaBundle.java │ │ │ │ │ ├── TerracottaManager.java │ │ │ │ │ ├── TerracottaMetadata.java │ │ │ │ │ ├── TerracottaNodeList.java │ │ │ │ │ ├── TerracottaState.java │ │ │ │ │ ├── profile/ │ │ │ │ │ │ ├── ProfileKind.java │ │ │ │ │ │ └── TerracottaProfile.java │ │ │ │ │ └── provider/ │ │ │ │ │ ├── AbstractTerracottaProvider.java │ │ │ │ │ ├── GeneralProvider.java │ │ │ │ │ └── MacOSProvider.java │ │ │ │ ├── theme/ │ │ │ │ │ ├── Theme.java │ │ │ │ │ ├── ThemeColor.java │ │ │ │ │ └── Themes.java │ │ │ │ ├── ui/ │ │ │ │ │ ├── Controllers.java │ │ │ │ │ ├── CrashWindow.java │ │ │ │ │ ├── DialogController.java │ │ │ │ │ ├── DialogUtils.java │ │ │ │ │ ├── FXUtils.java │ │ │ │ │ ├── GameCrashWindow.java │ │ │ │ │ ├── HTMLRenderer.java │ │ │ │ │ ├── InstallerItem.java │ │ │ │ │ ├── ListPageBase.java │ │ │ │ │ ├── LogWindow.java │ │ │ │ │ ├── SVG.java │ │ │ │ │ ├── SVGContainer.java │ │ │ │ │ ├── ScrollUtils.java │ │ │ │ │ ├── ToolbarListPageSkin.java │ │ │ │ │ ├── UpgradeDialog.java │ │ │ │ │ ├── WeakListenerHolder.java │ │ │ │ │ ├── WebPage.java │ │ │ │ │ ├── WindowsNativeUtils.java │ │ │ │ │ ├── account/ │ │ │ │ │ │ ├── AccountAdvancedListItem.java │ │ │ │ │ │ ├── AccountListItem.java │ │ │ │ │ │ ├── AccountListItemSkin.java │ │ │ │ │ │ ├── AccountListPage.java │ │ │ │ │ │ ├── AccountListPopupMenu.java │ │ │ │ │ │ ├── AddAuthlibInjectorServerPane.java │ │ │ │ │ │ ├── ClassicAccountLoginDialog.java │ │ │ │ │ │ ├── CreateAccountPane.java │ │ │ │ │ │ ├── MicrosoftAccountLoginPane.java │ │ │ │ │ │ └── OfflineAccountSkinPane.java │ │ │ │ │ ├── animation/ │ │ │ │ │ │ ├── AnimationUtils.java │ │ │ │ │ │ ├── ContainerAnimations.java │ │ │ │ │ │ ├── Motion.java │ │ │ │ │ │ └── TransitionPane.java │ │ │ │ │ ├── construct/ │ │ │ │ │ │ ├── AdvancedListBox.java │ │ │ │ │ │ ├── AdvancedListItem.java │ │ │ │ │ │ ├── AdvancedListItemSkin.java │ │ │ │ │ │ ├── ClassTitle.java │ │ │ │ │ │ ├── ComponentList.java │ │ │ │ │ │ ├── ComponentSublist.java │ │ │ │ │ │ ├── ComponentSublistWrapper.java │ │ │ │ │ │ ├── ControlSkinBase.java │ │ │ │ │ │ ├── DialogAware.java │ │ │ │ │ │ ├── DialogCloseEvent.java │ │ │ │ │ │ ├── DialogPane.java │ │ │ │ │ │ ├── DoubleValidator.java │ │ │ │ │ │ ├── FileSelector.java │ │ │ │ │ │ ├── FloatScrollBarSkin.java │ │ │ │ │ │ ├── FontComboBox.java │ │ │ │ │ │ ├── HintPane.java │ │ │ │ │ │ ├── IconedItem.java │ │ │ │ │ │ ├── IconedMenuItem.java │ │ │ │ │ │ ├── ImageContainer.java │ │ │ │ │ │ ├── ImagePickerItem.java │ │ │ │ │ │ ├── InputDialogPane.java │ │ │ │ │ │ ├── JFXCheckBoxTableCell.java │ │ │ │ │ │ ├── JFXDialogPane.java │ │ │ │ │ │ ├── JFXHyperlink.java │ │ │ │ │ │ ├── LineButton.java │ │ │ │ │ │ ├── LineButtonBase.java │ │ │ │ │ │ ├── LineComponent.java │ │ │ │ │ │ ├── LineFileChooserButton.java │ │ │ │ │ │ ├── LinePane.java │ │ │ │ │ │ ├── LineSelectButton.java │ │ │ │ │ │ ├── LineTextPane.java │ │ │ │ │ │ ├── LineToggleButton.java │ │ │ │ │ │ ├── MDListCell.java │ │ │ │ │ │ ├── MenuSeparator.java │ │ │ │ │ │ ├── MenuUpDownButton.java │ │ │ │ │ │ ├── MessageDialogPane.java │ │ │ │ │ │ ├── MultiFileItem.java │ │ │ │ │ │ ├── Navigator.java │ │ │ │ │ │ ├── NoPaddingComponent.java │ │ │ │ │ │ ├── NoneMultipleSelectionModel.java │ │ │ │ │ │ ├── NumberValidator.java │ │ │ │ │ │ ├── OptionsList.java │ │ │ │ │ │ ├── OptionsListSkin.java │ │ │ │ │ │ ├── PageAware.java │ │ │ │ │ │ ├── PageCloseEvent.java │ │ │ │ │ │ ├── PopupMenu.java │ │ │ │ │ │ ├── PromptDialogPane.java │ │ │ │ │ │ ├── RequiredValidator.java │ │ │ │ │ │ ├── RipplerContainer.java │ │ │ │ │ │ ├── SpinnerPane.java │ │ │ │ │ │ ├── TabControl.java │ │ │ │ │ │ ├── TabHeader.java │ │ │ │ │ │ ├── TaskExecutorDialogPane.java │ │ │ │ │ │ ├── TaskListPane.java │ │ │ │ │ │ ├── TwoLineListItem.java │ │ │ │ │ │ ├── URLValidator.java │ │ │ │ │ │ └── Validator.java │ │ │ │ │ ├── decorator/ │ │ │ │ │ │ ├── Decorator.java │ │ │ │ │ │ ├── DecoratorAnimatedPage.java │ │ │ │ │ │ ├── DecoratorController.java │ │ │ │ │ │ ├── DecoratorPage.java │ │ │ │ │ │ ├── DecoratorSkin.java │ │ │ │ │ │ ├── DecoratorTransitionPage.java │ │ │ │ │ │ └── DecoratorWizardDisplayer.java │ │ │ │ │ ├── download/ │ │ │ │ │ │ ├── AbstractInstallersPage.java │ │ │ │ │ │ ├── AdditionalInstallersPage.java │ │ │ │ │ │ ├── DownloadPage.java │ │ │ │ │ │ ├── InstallersPage.java │ │ │ │ │ │ ├── LocalModpackPage.java │ │ │ │ │ │ ├── ModpackInstallWizardProvider.java │ │ │ │ │ │ ├── ModpackPage.java │ │ │ │ │ │ ├── ModpackSelectionPage.java │ │ │ │ │ │ ├── RemoteModpackPage.java │ │ │ │ │ │ ├── UpdateInstallerWizardProvider.java │ │ │ │ │ │ ├── VanillaInstallWizardProvider.java │ │ │ │ │ │ └── VersionsPage.java │ │ │ │ │ ├── export/ │ │ │ │ │ │ ├── ExportWizardProvider.java │ │ │ │ │ │ ├── ModpackFileSelectionPage.java │ │ │ │ │ │ ├── ModpackInfoPage.java │ │ │ │ │ │ └── ModpackTypeSelectionPage.java │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── AnimationImage.java │ │ │ │ │ │ ├── ImageLoader.java │ │ │ │ │ │ ├── ImageUtils.java │ │ │ │ │ │ ├── apng/ │ │ │ │ │ │ │ ├── Png.java │ │ │ │ │ │ │ ├── PngAnimationType.java │ │ │ │ │ │ │ ├── PngChunkCode.java │ │ │ │ │ │ │ ├── PngColourType.java │ │ │ │ │ │ │ ├── PngConstants.java │ │ │ │ │ │ │ ├── PngFilter.java │ │ │ │ │ │ │ ├── PngScanlineBuffer.java │ │ │ │ │ │ │ ├── argb8888/ │ │ │ │ │ │ │ │ ├── Argb8888Bitmap.java │ │ │ │ │ │ │ │ ├── Argb8888BitmapSequence.java │ │ │ │ │ │ │ │ ├── Argb8888BitmapSequenceDirector.java │ │ │ │ │ │ │ │ ├── Argb8888Director.java │ │ │ │ │ │ │ │ ├── Argb8888Palette.java │ │ │ │ │ │ │ │ ├── Argb8888Processor.java │ │ │ │ │ │ │ │ ├── Argb8888Processors.java │ │ │ │ │ │ │ │ ├── Argb8888ScanlineProcessor.java │ │ │ │ │ │ │ │ ├── BasicArgb8888Director.java │ │ │ │ │ │ │ │ └── DefaultImageArgb8888Director.java │ │ │ │ │ │ │ ├── chunks/ │ │ │ │ │ │ │ │ ├── PngAnimationControl.java │ │ │ │ │ │ │ │ ├── PngFrameControl.java │ │ │ │ │ │ │ │ ├── PngGamma.java │ │ │ │ │ │ │ │ ├── PngHeader.java │ │ │ │ │ │ │ │ └── PngPalette.java │ │ │ │ │ │ │ ├── error/ │ │ │ │ │ │ │ │ ├── PngException.java │ │ │ │ │ │ │ │ ├── PngFeatureException.java │ │ │ │ │ │ │ │ └── PngIntegrityException.java │ │ │ │ │ │ │ ├── map/ │ │ │ │ │ │ │ │ ├── PngChunkMap.java │ │ │ │ │ │ │ │ ├── PngMap.java │ │ │ │ │ │ │ │ └── PngMapReader.java │ │ │ │ │ │ │ ├── package-info.java │ │ │ │ │ │ │ ├── reader/ │ │ │ │ │ │ │ │ ├── BasicScanlineProcessor.java │ │ │ │ │ │ │ │ ├── DefaultPngChunkReader.java │ │ │ │ │ │ │ │ ├── PngAtOnceSource.java │ │ │ │ │ │ │ │ ├── PngChunkProcessor.java │ │ │ │ │ │ │ │ ├── PngChunkReader.java │ │ │ │ │ │ │ │ ├── PngReadHelper.java │ │ │ │ │ │ │ │ ├── PngReader.java │ │ │ │ │ │ │ │ ├── PngScanlineProcessor.java │ │ │ │ │ │ │ │ ├── PngSource.java │ │ │ │ │ │ │ │ └── PngStreamSource.java │ │ │ │ │ │ │ └── util/ │ │ │ │ │ │ │ ├── InputStreamSlice.java │ │ │ │ │ │ │ ├── PartialInflaterInputStream.java │ │ │ │ │ │ │ ├── PngContainer.java │ │ │ │ │ │ │ ├── PngContainerBuilder.java │ │ │ │ │ │ │ └── PngContainerProcessor.java │ │ │ │ │ │ └── internal/ │ │ │ │ │ │ └── AnimationImageImpl.java │ │ │ │ │ ├── main/ │ │ │ │ │ │ ├── AboutPage.java │ │ │ │ │ │ ├── DownloadSettingsPage.java │ │ │ │ │ │ ├── FeedbackPage.java │ │ │ │ │ │ ├── HelpPage.java │ │ │ │ │ │ ├── JavaDownloadDialog.java │ │ │ │ │ │ ├── JavaInstallPage.java │ │ │ │ │ │ ├── JavaManagementPage.java │ │ │ │ │ │ ├── JavaRestorePage.java │ │ │ │ │ │ ├── LauncherSettingsPage.java │ │ │ │ │ │ ├── MainPage.java │ │ │ │ │ │ ├── PersonalizationPage.java │ │ │ │ │ │ ├── RootPage.java │ │ │ │ │ │ └── SettingsPage.java │ │ │ │ │ ├── nbt/ │ │ │ │ │ │ ├── NBTEditorPage.java │ │ │ │ │ │ ├── NBTFileType.java │ │ │ │ │ │ ├── NBTTagType.java │ │ │ │ │ │ └── NBTTreeView.java │ │ │ │ │ ├── profile/ │ │ │ │ │ │ ├── ProfileListItem.java │ │ │ │ │ │ ├── ProfileListItemSkin.java │ │ │ │ │ │ └── ProfilePage.java │ │ │ │ │ ├── skin/ │ │ │ │ │ │ ├── FunctionHelper.java │ │ │ │ │ │ ├── SkinAnimation.java │ │ │ │ │ │ ├── SkinAnimationPlayer.java │ │ │ │ │ │ ├── SkinCanvas.java │ │ │ │ │ │ ├── SkinCube.java │ │ │ │ │ │ ├── SkinGroup.java │ │ │ │ │ │ ├── SkinHelper.java │ │ │ │ │ │ ├── SkinMultipleCubes.java │ │ │ │ │ │ ├── SkinTransition.java │ │ │ │ │ │ └── animation/ │ │ │ │ │ │ ├── SkinAniRunning.java │ │ │ │ │ │ └── SkinAniWavingArms.java │ │ │ │ │ ├── terracotta/ │ │ │ │ │ │ ├── TerracottaControllerPage.java │ │ │ │ │ │ └── TerracottaPage.java │ │ │ │ │ ├── versions/ │ │ │ │ │ │ ├── AdvancedVersionSettingPage.java │ │ │ │ │ │ ├── DatapackListPage.java │ │ │ │ │ │ ├── DatapackListPageSkin.java │ │ │ │ │ │ ├── DownloadListPage.java │ │ │ │ │ │ ├── DownloadPage.java │ │ │ │ │ │ ├── GameAdvancedListItem.java │ │ │ │ │ │ ├── GameItem.java │ │ │ │ │ │ ├── GameListCell.java │ │ │ │ │ │ ├── GameListItem.java │ │ │ │ │ │ ├── GameListPage.java │ │ │ │ │ │ ├── GameListPopupMenu.java │ │ │ │ │ │ ├── HMCLLocalizedDownloadListPage.java │ │ │ │ │ │ ├── InstallerListPage.java │ │ │ │ │ │ ├── ModCheckUpdatesTask.java │ │ │ │ │ │ ├── ModListPage.java │ │ │ │ │ │ ├── ModListPageSkin.java │ │ │ │ │ │ ├── ModTranslations.java │ │ │ │ │ │ ├── ModUpdatesPage.java │ │ │ │ │ │ ├── ResourcepackListPage.java │ │ │ │ │ │ ├── SchematicsPage.java │ │ │ │ │ │ ├── VersionIconDialog.java │ │ │ │ │ │ ├── VersionPage.java │ │ │ │ │ │ ├── VersionSettingsPage.java │ │ │ │ │ │ ├── Versions.java │ │ │ │ │ │ ├── WorldBackupTask.java │ │ │ │ │ │ ├── WorldBackupsPage.java │ │ │ │ │ │ ├── WorldExportPage.java │ │ │ │ │ │ ├── WorldExportPageSkin.java │ │ │ │ │ │ ├── WorldInfoPage.java │ │ │ │ │ │ ├── WorldListPage.java │ │ │ │ │ │ ├── WorldManagePage.java │ │ │ │ │ │ └── WorldManageUIUtils.java │ │ │ │ │ └── wizard/ │ │ │ │ │ ├── AbstractWizardDisplayer.java │ │ │ │ │ ├── Navigation.java │ │ │ │ │ ├── Refreshable.java │ │ │ │ │ ├── SinglePageWizardProvider.java │ │ │ │ │ ├── Summary.java │ │ │ │ │ ├── TaskExecutorDialogWizardDisplayer.java │ │ │ │ │ ├── WizardController.java │ │ │ │ │ ├── WizardDisplayer.java │ │ │ │ │ ├── WizardPage.java │ │ │ │ │ ├── WizardProvider.java │ │ │ │ │ └── WizardSinglePage.java │ │ │ │ ├── upgrade/ │ │ │ │ │ ├── ExecutableHeaderHelper.java │ │ │ │ │ ├── HMCLDownloadTask.java │ │ │ │ │ ├── IntegrityChecker.java │ │ │ │ │ ├── RemoteVersion.java │ │ │ │ │ ├── UpdateChannel.java │ │ │ │ │ ├── UpdateChecker.java │ │ │ │ │ └── UpdateHandler.java │ │ │ │ └── util/ │ │ │ │ ├── AggregatedObservableList.java │ │ │ │ ├── AprilFools.java │ │ │ │ ├── ChunkBaseApp.java │ │ │ │ ├── CrashReporter.java │ │ │ │ ├── FileSaver.java │ │ │ │ ├── JavaFXPatcher.java │ │ │ │ ├── Lazy.java │ │ │ │ ├── NativePatcher.java │ │ │ │ ├── QrCodeUtils.java │ │ │ │ ├── RemoteImageLoader.java │ │ │ │ ├── ResourceNotFoundError.java │ │ │ │ ├── Restarter.java │ │ │ │ ├── SelfDependencyPatcher.java │ │ │ │ ├── SwingFXUtils.java │ │ │ │ ├── TaskCancellationAction.java │ │ │ │ ├── i18n/ │ │ │ │ │ ├── I18n.java │ │ │ │ │ ├── MinecraftWiki.java │ │ │ │ │ ├── SupportedLocale.java │ │ │ │ │ └── translator/ │ │ │ │ │ ├── Translator.java │ │ │ │ │ ├── Translator_en_Qabs.java │ │ │ │ │ └── Translator_lzh.java │ │ │ │ └── url/ │ │ │ │ ├── HMCLURLStreamHandlerProvider.java │ │ │ │ └── data/ │ │ │ │ ├── DataURLConnection.java │ │ │ │ ├── DataURLHandle.java │ │ │ │ └── DataUri.java │ │ │ └── resources/ │ │ │ ├── META-INF/ │ │ │ │ └── services/ │ │ │ │ └── java.net.spi.URLStreamHandlerProvider │ │ │ └── assets/ │ │ │ ├── HMCLauncher.sh │ │ │ ├── about/ │ │ │ │ ├── deps.json │ │ │ │ └── thanks.json │ │ │ ├── css/ │ │ │ │ ├── blue.css │ │ │ │ ├── brightness-dark.css │ │ │ │ ├── brightness-light.css │ │ │ │ ├── font.css │ │ │ │ └── root.css │ │ │ ├── hmcl_signature_publickey.der │ │ │ ├── lang/ │ │ │ │ ├── I18N.properties │ │ │ │ ├── I18N_ar.properties │ │ │ │ ├── I18N_es.properties │ │ │ │ ├── I18N_ja.properties │ │ │ │ ├── I18N_lzh.properties │ │ │ │ ├── I18N_ru.properties │ │ │ │ ├── I18N_uk.properties │ │ │ │ ├── I18N_zh.properties │ │ │ │ └── I18N_zh_CN.properties │ │ │ ├── microsoft_auth.html │ │ │ ├── mod_data.txt │ │ │ ├── modpack_data.txt │ │ │ ├── natives.json │ │ │ ├── openjfx-dependencies.json │ │ │ └── terracotta.json │ │ └── test/ │ │ ├── java/ │ │ │ └── org/ │ │ │ └── jackhuang/ │ │ │ └── hmcl/ │ │ │ ├── JavaFXLauncher.java │ │ │ ├── setting/ │ │ │ │ └── ThemeColorTest.java │ │ │ ├── ui/ │ │ │ │ ├── GameCrashWindowTest.java │ │ │ │ ├── SVGTest.java │ │ │ │ ├── image/ │ │ │ │ │ ├── ImageUtilsTest.java │ │ │ │ │ └── ImageViewTest.java │ │ │ │ └── skin/ │ │ │ │ ├── SkinCanvasSupport.java │ │ │ │ └── test/ │ │ │ │ └── Test.java │ │ │ └── util/ │ │ │ ├── AggregatedObservableListTest.java │ │ │ ├── i18n/ │ │ │ │ └── translator/ │ │ │ │ └── TranslatorTest.java │ │ │ └── url/ │ │ │ └── data/ │ │ │ └── DataUriTest.java │ │ └── resources/ │ │ └── image/ │ │ └── 16x16.apng │ └── terracotta-template.json ├── HMCLBoot/ │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── org/ │ │ │ └── jackhuang/ │ │ │ └── hmcl/ │ │ │ ├── BootProperties.java │ │ │ ├── DesktopUtils.java │ │ │ ├── EntryPoint.java │ │ │ ├── Main.java │ │ │ └── util/ │ │ │ ├── SwingUtils.java │ │ │ └── UTF8Control.java │ │ └── resources/ │ │ └── assets/ │ │ └── lang/ │ │ ├── boot.properties │ │ ├── boot_es.properties │ │ ├── boot_zh.properties │ │ └── boot_zh_Hant.properties │ └── test/ │ └── java/ │ └── org/ │ └── jackhuang/ │ └── hmcl/ │ └── MainTest.java ├── HMCLCore/ │ ├── build.gradle.kts │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── org/ │ │ │ └── jackhuang/ │ │ │ └── hmcl/ │ │ │ ├── auth/ │ │ │ │ ├── Account.java │ │ │ │ ├── AccountFactory.java │ │ │ │ ├── AuthInfo.java │ │ │ │ ├── AuthenticationException.java │ │ │ │ ├── CharacterDeletedException.java │ │ │ │ ├── CharacterSelector.java │ │ │ │ ├── ClassicAccount.java │ │ │ │ ├── CredentialExpiredException.java │ │ │ │ ├── NoCharacterException.java │ │ │ │ ├── NoSelectedCharacterException.java │ │ │ │ ├── NotLoggedInException.java │ │ │ │ ├── OAuth.java │ │ │ │ ├── OAuthAccount.java │ │ │ │ ├── ServerDisconnectException.java │ │ │ │ ├── ServerResponseMalformedException.java │ │ │ │ ├── authlibinjector/ │ │ │ │ │ ├── AuthlibInjectorAccount.java │ │ │ │ │ ├── AuthlibInjectorAccountFactory.java │ │ │ │ │ ├── AuthlibInjectorArtifactInfo.java │ │ │ │ │ ├── AuthlibInjectorArtifactProvider.java │ │ │ │ │ ├── AuthlibInjectorDnD.java │ │ │ │ │ ├── AuthlibInjectorDownloadException.java │ │ │ │ │ ├── AuthlibInjectorDownloader.java │ │ │ │ │ ├── AuthlibInjectorExtractor.java │ │ │ │ │ ├── AuthlibInjectorProvider.java │ │ │ │ │ ├── AuthlibInjectorServer.java │ │ │ │ │ ├── BoundAuthlibInjectorAccountFactory.java │ │ │ │ │ └── SimpleAuthlibInjectorArtifactProvider.java │ │ │ │ ├── microsoft/ │ │ │ │ │ ├── MicrosoftAccount.java │ │ │ │ │ ├── MicrosoftAccountFactory.java │ │ │ │ │ ├── MicrosoftService.java │ │ │ │ │ └── MicrosoftSession.java │ │ │ │ ├── offline/ │ │ │ │ │ ├── OfflineAccount.java │ │ │ │ │ ├── OfflineAccountFactory.java │ │ │ │ │ ├── Skin.java │ │ │ │ │ ├── Texture.java │ │ │ │ │ └── YggdrasilServer.java │ │ │ │ └── yggdrasil/ │ │ │ │ ├── CompleteGameProfile.java │ │ │ │ ├── GameProfile.java │ │ │ │ ├── PropertyMapSerializer.java │ │ │ │ ├── RemoteAuthenticationException.java │ │ │ │ ├── Texture.java │ │ │ │ ├── TextureModel.java │ │ │ │ ├── TextureType.java │ │ │ │ ├── User.java │ │ │ │ ├── YggdrasilAccount.java │ │ │ │ ├── YggdrasilProvider.java │ │ │ │ ├── YggdrasilService.java │ │ │ │ └── YggdrasilSession.java │ │ │ ├── download/ │ │ │ │ ├── AbstractDependencyManager.java │ │ │ │ ├── ArtifactMalformedException.java │ │ │ │ ├── AutoDownloadProvider.java │ │ │ │ ├── BMCLAPIDownloadProvider.java │ │ │ │ ├── DefaultCacheRepository.java │ │ │ │ ├── DefaultDependencyManager.java │ │ │ │ ├── DefaultGameBuilder.java │ │ │ │ ├── DependencyManager.java │ │ │ │ ├── DownloadProvider.java │ │ │ │ ├── DownloadProviderWrapper.java │ │ │ │ ├── GameBuilder.java │ │ │ │ ├── LibraryAnalyzer.java │ │ │ │ ├── MaintainTask.java │ │ │ │ ├── MojangDownloadProvider.java │ │ │ │ ├── MultipleSourceVersionList.java │ │ │ │ ├── RemoteVersion.java │ │ │ │ ├── UnsupportedInstallationException.java │ │ │ │ ├── VersionList.java │ │ │ │ ├── VersionMismatchException.java │ │ │ │ ├── cleanroom/ │ │ │ │ │ ├── CleanroomInstallTask.java │ │ │ │ │ ├── CleanroomRemoteVersion.java │ │ │ │ │ └── CleanroomVersionList.java │ │ │ │ ├── fabric/ │ │ │ │ │ ├── FabricAPIInstallTask.java │ │ │ │ │ ├── FabricAPIRemoteVersion.java │ │ │ │ │ ├── FabricAPIVersionList.java │ │ │ │ │ ├── FabricInstallTask.java │ │ │ │ │ ├── FabricRemoteVersion.java │ │ │ │ │ └── FabricVersionList.java │ │ │ │ ├── forge/ │ │ │ │ │ ├── ForgeBMCLVersionList.java │ │ │ │ │ ├── ForgeInstall.java │ │ │ │ │ ├── ForgeInstallProfile.java │ │ │ │ │ ├── ForgeInstallTask.java │ │ │ │ │ ├── ForgeNewInstallProfile.java │ │ │ │ │ ├── ForgeNewInstallTask.java │ │ │ │ │ ├── ForgeOldInstallTask.java │ │ │ │ │ ├── ForgeRemoteVersion.java │ │ │ │ │ ├── ForgeVersion.java │ │ │ │ │ ├── ForgeVersionList.java │ │ │ │ │ └── ForgeVersionRoot.java │ │ │ │ ├── game/ │ │ │ │ │ ├── GameAssetDownloadTask.java │ │ │ │ │ ├── GameAssetIndexDownloadTask.java │ │ │ │ │ ├── GameDownloadTask.java │ │ │ │ │ ├── GameInstallTask.java │ │ │ │ │ ├── GameLibrariesTask.java │ │ │ │ │ ├── GameRemoteLatestVersions.java │ │ │ │ │ ├── GameRemoteVersion.java │ │ │ │ │ ├── GameRemoteVersionInfo.java │ │ │ │ │ ├── GameRemoteVersions.java │ │ │ │ │ ├── GameVerificationFixTask.java │ │ │ │ │ ├── GameVersionList.java │ │ │ │ │ ├── LibraryDownloadException.java │ │ │ │ │ ├── LibraryDownloadTask.java │ │ │ │ │ ├── VersionJsonDownloadTask.java │ │ │ │ │ └── VersionJsonSaveTask.java │ │ │ │ ├── java/ │ │ │ │ │ ├── JavaDistribution.java │ │ │ │ │ ├── JavaPackageType.java │ │ │ │ │ ├── JavaRemoteVersion.java │ │ │ │ │ ├── disco/ │ │ │ │ │ │ ├── DiscoFetchJavaListTask.java │ │ │ │ │ │ ├── DiscoJavaDistribution.java │ │ │ │ │ │ ├── DiscoJavaRemoteVersion.java │ │ │ │ │ │ ├── DiscoRemoteFileInfo.java │ │ │ │ │ │ └── DiscoResult.java │ │ │ │ │ └── mojang/ │ │ │ │ │ ├── MojangJavaDistribution.java │ │ │ │ │ ├── MojangJavaDownloadTask.java │ │ │ │ │ ├── MojangJavaDownloads.java │ │ │ │ │ ├── MojangJavaRemoteFiles.java │ │ │ │ │ └── MojangJavaRemoteVersion.java │ │ │ │ ├── legacyfabric/ │ │ │ │ │ ├── LegacyFabricAPIInstallTask.java │ │ │ │ │ ├── LegacyFabricAPIRemoteVersion.java │ │ │ │ │ ├── LegacyFabricAPIVersionList.java │ │ │ │ │ ├── LegacyFabricInstallTask.java │ │ │ │ │ ├── LegacyFabricRemoteVersion.java │ │ │ │ │ └── LegacyFabricVersionList.java │ │ │ │ ├── liteloader/ │ │ │ │ │ ├── LiteLoaderBMCLVersionList.java │ │ │ │ │ ├── LiteLoaderBranch.java │ │ │ │ │ ├── LiteLoaderGameVersions.java │ │ │ │ │ ├── LiteLoaderInstallTask.java │ │ │ │ │ ├── LiteLoaderRemoteVersion.java │ │ │ │ │ ├── LiteLoaderRepository.java │ │ │ │ │ ├── LiteLoaderVersion.java │ │ │ │ │ ├── LiteLoaderVersionList.java │ │ │ │ │ ├── LiteLoaderVersionsMeta.java │ │ │ │ │ └── LiteLoaderVersionsRoot.java │ │ │ │ ├── neoforge/ │ │ │ │ │ ├── NeoForgeBMCLVersionList.java │ │ │ │ │ ├── NeoForgeInstallTask.java │ │ │ │ │ ├── NeoForgeOfficialVersionList.java │ │ │ │ │ ├── NeoForgeOldInstallTask.java │ │ │ │ │ └── NeoForgeRemoteVersion.java │ │ │ │ ├── optifine/ │ │ │ │ │ ├── OptiFineBMCLVersionList.java │ │ │ │ │ ├── OptiFineInstallTask.java │ │ │ │ │ └── OptiFineRemoteVersion.java │ │ │ │ └── quilt/ │ │ │ │ ├── QuiltAPIInstallTask.java │ │ │ │ ├── QuiltAPIRemoteVersion.java │ │ │ │ ├── QuiltAPIVersionList.java │ │ │ │ ├── QuiltInstallTask.java │ │ │ │ ├── QuiltRemoteVersion.java │ │ │ │ └── QuiltVersionList.java │ │ │ ├── event/ │ │ │ │ ├── Event.java │ │ │ │ ├── EventBus.java │ │ │ │ ├── EventManager.java │ │ │ │ ├── EventPriority.java │ │ │ │ ├── GameJsonParseFailedEvent.java │ │ │ │ ├── JVMLaunchFailedEvent.java │ │ │ │ ├── LoadedOneVersionEvent.java │ │ │ │ ├── ProcessExitedAbnormallyEvent.java │ │ │ │ ├── ProcessStoppedEvent.java │ │ │ │ ├── RefreshedVersionsEvent.java │ │ │ │ ├── RefreshingVersionsEvent.java │ │ │ │ ├── RemoveVersionEvent.java │ │ │ │ └── RenameVersionEvent.java │ │ │ ├── game/ │ │ │ │ ├── Argument.java │ │ │ │ ├── Arguments.java │ │ │ │ ├── Artifact.java │ │ │ │ ├── AssetIndex.java │ │ │ │ ├── AssetIndexInfo.java │ │ │ │ ├── AssetObject.java │ │ │ │ ├── ClassicVersion.java │ │ │ │ ├── CompatibilityRule.java │ │ │ │ ├── CrashReportAnalyzer.java │ │ │ │ ├── DefaultGameRepository.java │ │ │ │ ├── DownloadInfo.java │ │ │ │ ├── DownloadType.java │ │ │ │ ├── ExtractRules.java │ │ │ │ ├── GameDirectoryType.java │ │ │ │ ├── GameDumpGenerator.java │ │ │ │ ├── GameException.java │ │ │ │ ├── GameJavaVersion.java │ │ │ │ ├── GameRepository.java │ │ │ │ ├── GameVersion.java │ │ │ │ ├── IdDownloadInfo.java │ │ │ │ ├── JavaVersionConstraint.java │ │ │ │ ├── LaunchOptions.java │ │ │ │ ├── LibrariesDownloadInfo.java │ │ │ │ ├── Library.java │ │ │ │ ├── LibraryDownloadInfo.java │ │ │ │ ├── LoggingInfo.java │ │ │ │ ├── NativesDirectoryType.java │ │ │ │ ├── OSRestriction.java │ │ │ │ ├── ProcessPriority.java │ │ │ │ ├── ProxyOption.java │ │ │ │ ├── QuickPlayOption.java │ │ │ │ ├── ReleaseType.java │ │ │ │ ├── Renderer.java │ │ │ │ ├── RuledArgument.java │ │ │ │ ├── SimpleVersionProvider.java │ │ │ │ ├── StringArgument.java │ │ │ │ ├── Version.java │ │ │ │ ├── VersionJsonType.java │ │ │ │ ├── VersionLibraryBuilder.java │ │ │ │ ├── VersionNotFoundException.java │ │ │ │ ├── VersionProvider.java │ │ │ │ ├── World.java │ │ │ │ ├── WorldLockedException.java │ │ │ │ └── tlauncher/ │ │ │ │ ├── TLauncherLibrary.java │ │ │ │ └── TLauncherVersion.java │ │ │ ├── java/ │ │ │ │ ├── JavaInfo.java │ │ │ │ ├── JavaRepository.java │ │ │ │ └── JavaRuntime.java │ │ │ ├── launch/ │ │ │ │ ├── CommandTooLongException.java │ │ │ │ ├── DefaultLauncher.java │ │ │ │ ├── ExecutionPolicyLimitException.java │ │ │ │ ├── ExitWaiter.java │ │ │ │ ├── Launcher.java │ │ │ │ ├── NotDecompressingNativesException.java │ │ │ │ ├── PermissionException.java │ │ │ │ ├── ProcessCreationException.java │ │ │ │ ├── ProcessListener.java │ │ │ │ └── StreamPump.java │ │ │ ├── mod/ │ │ │ │ ├── Datapack.java │ │ │ │ ├── LocalMod.java │ │ │ │ ├── LocalModFile.java │ │ │ │ ├── MinecraftInstanceTask.java │ │ │ │ ├── MismatchedModpackTypeException.java │ │ │ │ ├── ModAdviser.java │ │ │ │ ├── ModLoaderType.java │ │ │ │ ├── ModManager.java │ │ │ │ ├── Modpack.java │ │ │ │ ├── ModpackCompletionException.java │ │ │ │ ├── ModpackConfiguration.java │ │ │ │ ├── ModpackExportInfo.java │ │ │ │ ├── ModpackInstallTask.java │ │ │ │ ├── ModpackManifest.java │ │ │ │ ├── ModpackProvider.java │ │ │ │ ├── ModpackUpdateTask.java │ │ │ │ ├── RemoteMod.java │ │ │ │ ├── RemoteModRepository.java │ │ │ │ ├── UnsupportedModpackException.java │ │ │ │ ├── curse/ │ │ │ │ │ ├── CurseAddon.java │ │ │ │ │ ├── CurseCompletionTask.java │ │ │ │ │ ├── CurseForgeRemoteModRepository.java │ │ │ │ │ ├── CurseInstallTask.java │ │ │ │ │ ├── CurseManifest.java │ │ │ │ │ ├── CurseManifestFile.java │ │ │ │ │ ├── CurseManifestMinecraft.java │ │ │ │ │ ├── CurseManifestModLoader.java │ │ │ │ │ ├── CurseMetaMod.java │ │ │ │ │ └── CurseModpackProvider.java │ │ │ │ ├── mcbbs/ │ │ │ │ │ ├── McbbsModpackCompletionTask.java │ │ │ │ │ ├── McbbsModpackExportTask.java │ │ │ │ │ ├── McbbsModpackLocalInstallTask.java │ │ │ │ │ ├── McbbsModpackManifest.java │ │ │ │ │ ├── McbbsModpackProvider.java │ │ │ │ │ └── McbbsModpackRemoteInstallTask.java │ │ │ │ ├── modinfo/ │ │ │ │ │ ├── FabricModMetadata.java │ │ │ │ │ ├── ForgeNewModMetadata.java │ │ │ │ │ ├── ForgeOldModMetadata.java │ │ │ │ │ ├── ForgeOldModMetadataLst.java │ │ │ │ │ ├── LiteModMetadata.java │ │ │ │ │ ├── PackMcMeta.java │ │ │ │ │ └── QuiltModMetadata.java │ │ │ │ ├── modrinth/ │ │ │ │ │ ├── ModrinthCompletionTask.java │ │ │ │ │ ├── ModrinthInstallTask.java │ │ │ │ │ ├── ModrinthManifest.java │ │ │ │ │ ├── ModrinthModpackExportTask.java │ │ │ │ │ ├── ModrinthModpackProvider.java │ │ │ │ │ └── ModrinthRemoteModRepository.java │ │ │ │ ├── multimc/ │ │ │ │ │ ├── MultiMCComponents.java │ │ │ │ │ ├── MultiMCInstanceConfiguration.java │ │ │ │ │ ├── MultiMCInstancePatch.java │ │ │ │ │ ├── MultiMCManifest.java │ │ │ │ │ ├── MultiMCModpackExportTask.java │ │ │ │ │ ├── MultiMCModpackInstallTask.java │ │ │ │ │ └── MultiMCModpackProvider.java │ │ │ │ └── server/ │ │ │ │ ├── ServerModpackCompletionTask.java │ │ │ │ ├── ServerModpackExportTask.java │ │ │ │ ├── ServerModpackLocalInstallTask.java │ │ │ │ ├── ServerModpackManifest.java │ │ │ │ ├── ServerModpackProvider.java │ │ │ │ └── ServerModpackRemoteInstallTask.java │ │ │ ├── resourcepack/ │ │ │ │ ├── ResourcepackFile.java │ │ │ │ ├── ResourcepackFolder.java │ │ │ │ └── ResourcepackZipFile.java │ │ │ ├── schematic/ │ │ │ │ └── LitematicFile.java │ │ │ ├── task/ │ │ │ │ ├── AsyncTaskExecutor.java │ │ │ │ ├── CacheFileTask.java │ │ │ │ ├── CompletableFutureTask.java │ │ │ │ ├── DownloadException.java │ │ │ │ ├── FetchTask.java │ │ │ │ ├── FileDownloadTask.java │ │ │ │ ├── GetTask.java │ │ │ │ ├── Schedulers.java │ │ │ │ ├── Task.java │ │ │ │ ├── TaskCompletableFuture.java │ │ │ │ ├── TaskEvent.java │ │ │ │ ├── TaskExecutor.java │ │ │ │ └── TaskListener.java │ │ │ └── util/ │ │ │ ├── ByteArray.java │ │ │ ├── CacheRepository.java │ │ │ ├── CircularArrayList.java │ │ │ ├── Constants.java │ │ │ ├── DataSizeUnit.java │ │ │ ├── DigestUtils.java │ │ │ ├── FXThread.java │ │ │ ├── FutureCallback.java │ │ │ ├── Holder.java │ │ │ ├── Immutable.java │ │ │ ├── InfiniteSizeList.java │ │ │ ├── InvocationDispatcher.java │ │ │ ├── KeyUtils.java │ │ │ ├── KeyValuePairUtils.java │ │ │ ├── Lang.java │ │ │ ├── Log4jLevel.java │ │ │ ├── MurmurHash2.java │ │ │ ├── Pair.java │ │ │ ├── Result.java │ │ │ ├── ServerAddress.java │ │ │ ├── SettingsMap.java │ │ │ ├── SimpleMultimap.java │ │ │ ├── StringUtils.java │ │ │ ├── ToStringBuilder.java │ │ │ ├── TypeUtils.java │ │ │ ├── function/ │ │ │ │ ├── ExceptionalBiConsumer.java │ │ │ │ ├── ExceptionalBiFunction.java │ │ │ │ ├── ExceptionalConsumer.java │ │ │ │ ├── ExceptionalFunction.java │ │ │ │ ├── ExceptionalPredicate.java │ │ │ │ ├── ExceptionalRunnable.java │ │ │ │ └── ExceptionalSupplier.java │ │ │ ├── gson/ │ │ │ │ ├── EnumOrdinalDeserializer.java │ │ │ │ ├── InstantTypeAdapter.java │ │ │ │ ├── JsonMap.java │ │ │ │ ├── JsonSerializable.java │ │ │ │ ├── JsonSubtype.java │ │ │ │ ├── JsonType.java │ │ │ │ ├── JsonTypeAdapterFactory.java │ │ │ │ ├── JsonUtils.java │ │ │ │ ├── LowerCaseEnumTypeAdapterFactory.java │ │ │ │ ├── ObservableSetting.java │ │ │ │ ├── PaintAdapter.java │ │ │ │ ├── PathTypeAdapter.java │ │ │ │ ├── RawPreservingObjectProperty.java │ │ │ │ ├── RawPreservingProperty.java │ │ │ │ ├── TolerableValidationException.java │ │ │ │ ├── UUIDTypeAdapter.java │ │ │ │ ├── Validation.java │ │ │ │ └── ValidationTypeAdapterFactory.java │ │ │ ├── i18n/ │ │ │ │ ├── DefaultResourceBundleControl.java │ │ │ │ ├── LocaleUtils.java │ │ │ │ ├── LocalizedText.java │ │ │ │ └── TextDirection.java │ │ │ ├── io/ │ │ │ │ ├── CSVTable.java │ │ │ │ ├── ChecksumMismatchException.java │ │ │ │ ├── CompressingUtils.java │ │ │ │ ├── ContentEncoding.java │ │ │ │ ├── DirectoryStructurePrinter.java │ │ │ │ ├── FileUtils.java │ │ │ │ ├── HttpMultipartRequest.java │ │ │ │ ├── HttpRequest.java │ │ │ │ ├── HttpServer.java │ │ │ │ ├── IOUtils.java │ │ │ │ ├── JarUtils.java │ │ │ │ ├── NetworkUtils.java │ │ │ │ ├── ResponseCodeException.java │ │ │ │ ├── Unzipper.java │ │ │ │ └── Zipper.java │ │ │ ├── javafx/ │ │ │ │ ├── AutomatedToggleGroup.java │ │ │ │ ├── BindingMapping.java │ │ │ │ ├── DirtyTracker.java │ │ │ │ ├── ExtendedProperties.java │ │ │ │ ├── MappedObservableList.java │ │ │ │ ├── MappedProperty.java │ │ │ │ ├── ObservableCache.java │ │ │ │ ├── ObservableHelper.java │ │ │ │ ├── ObservableOptionalCache.java │ │ │ │ ├── PropertyUtils.java │ │ │ │ ├── ReadWriteComposedProperty.java │ │ │ │ └── SafeStringConverter.java │ │ │ ├── logging/ │ │ │ │ ├── CallerFinder.java │ │ │ │ ├── LogEvent.java │ │ │ │ └── Logger.java │ │ │ ├── platform/ │ │ │ │ ├── Architecture.java │ │ │ │ ├── Bits.java │ │ │ │ ├── CommandBuilder.java │ │ │ │ ├── ManagedProcess.java │ │ │ │ ├── NativeUtils.java │ │ │ │ ├── OSVersion.java │ │ │ │ ├── OperatingSystem.java │ │ │ │ ├── Platform.java │ │ │ │ ├── SystemInfo.java │ │ │ │ ├── SystemUtils.java │ │ │ │ ├── UnsupportedPlatformException.java │ │ │ │ ├── hardware/ │ │ │ │ │ ├── CentralProcessor.java │ │ │ │ │ ├── FastFetchUtils.java │ │ │ │ │ ├── GraphicsCard.java │ │ │ │ │ ├── HardwareDetector.java │ │ │ │ │ ├── HardwareVendor.java │ │ │ │ │ └── PhysicalMemoryStatus.java │ │ │ │ ├── linux/ │ │ │ │ │ ├── LinuxCPUDetector.java │ │ │ │ │ ├── LinuxGPUDetector.java │ │ │ │ │ └── LinuxHardwareDetector.java │ │ │ │ ├── macos/ │ │ │ │ │ └── MacOSHardwareDetector.java │ │ │ │ └── windows/ │ │ │ │ ├── Advapi32.java │ │ │ │ ├── Dwmapi.java │ │ │ │ ├── Kernel32.java │ │ │ │ ├── WinConstants.java │ │ │ │ ├── WinReg.java │ │ │ │ ├── WinTypes.java │ │ │ │ ├── WindowsCPUDetector.java │ │ │ │ ├── WindowsGPUDetector.java │ │ │ │ └── WindowsHardwareDetector.java │ │ │ ├── skin/ │ │ │ │ ├── InvalidSkinException.java │ │ │ │ └── NormalizedSkin.java │ │ │ ├── tree/ │ │ │ │ ├── ArchiveFileTree.java │ │ │ │ ├── TarFileTree.java │ │ │ │ └── ZipFileTree.java │ │ │ └── versioning/ │ │ │ ├── GameVersionNumber.java │ │ │ ├── VersionNumber.java │ │ │ └── VersionRange.java │ │ └── resources/ │ │ └── assets/ │ │ ├── game/ │ │ │ ├── log4j2-1.12-debug.xml │ │ │ ├── log4j2-1.12.xml │ │ │ ├── log4j2-1.7-debug.xml │ │ │ ├── log4j2-1.7.xml │ │ │ ├── unlisted-versions.json │ │ │ ├── version-alias.csv │ │ │ └── versions.txt │ │ ├── lang/ │ │ │ ├── default_script.csv │ │ │ ├── language_aliases.csv │ │ │ ├── sublanguages.csv │ │ │ └── upside_down.txt │ │ └── platform/ │ │ └── amdgpu.ids │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── jackhuang/ │ │ └── hmcl/ │ │ ├── JavaFXLauncher.java │ │ ├── game/ │ │ │ └── CrashReportAnalyzerTest.java │ │ ├── mod/ │ │ │ └── curse/ │ │ │ └── CurseForgeRemoteModRepositoryTest.java │ │ ├── schematic/ │ │ │ └── LitematicFileTest.java │ │ └── util/ │ │ ├── ByteArrayTest.java │ │ ├── CircularArrayListTest.java │ │ ├── DataSizeUnitTest.java │ │ ├── KeyValuePairUtilsTest.java │ │ ├── OSVersionTest.java │ │ ├── ServerAddressTest.java │ │ ├── StringUtilsTest.java │ │ ├── TaskTest.java │ │ ├── TokenizerTest.java │ │ ├── gson/ │ │ │ ├── InstantTypeAdapterTest.java │ │ │ └── JsonUtilsTest.java │ │ ├── i18n/ │ │ │ └── LocaleUtilsTest.java │ │ ├── io/ │ │ │ ├── CSVTableTest.java │ │ │ ├── CompressingUtilsTest.java │ │ │ ├── FileUtilsTest.java │ │ │ └── NetworkUtilsTest.java │ │ ├── javafx/ │ │ │ └── MappedObservableListTest.java │ │ ├── platform/ │ │ │ ├── JavaRuntimeTest.java │ │ │ ├── hardware/ │ │ │ │ ├── CentralProcessorTest.java │ │ │ │ └── GraphicsCardTest.java │ │ │ └── windows/ │ │ │ ├── WinRegTest.java │ │ │ └── WindowsVersionTest.java │ │ ├── skin/ │ │ │ └── NormalizedSkinTest.java │ │ ├── tree/ │ │ │ └── ZipFileTreeTest.java │ │ └── versioning/ │ │ ├── GameVersionNumberTest.java │ │ ├── VersionNumberTest.java │ │ └── VersionRangeTest.java │ └── resources/ │ ├── crash-report/ │ │ ├── config.txt │ │ ├── debug_crash.txt │ │ ├── file_already_exists.txt │ │ ├── forge_error.txt │ │ ├── graphics_driver.txt │ │ ├── loader_exception_mod_crash.txt │ │ ├── loader_exception_mod_crash2.txt │ │ ├── loader_exception_mod_crash3.txt │ │ ├── loader_exception_mod_crash4.txt │ │ ├── loading_error_fabric.txt │ │ ├── mod/ │ │ │ ├── bettersprinting.txt │ │ │ ├── creativemd.txt │ │ │ ├── customnpc.txt │ │ │ ├── customskinloader.txt │ │ │ ├── flammpfeil.txt │ │ │ ├── ic2.txt │ │ │ ├── icycream.txt │ │ │ ├── mapletree.txt │ │ │ ├── nei.txt │ │ │ ├── neoforgeforest_optifine_incompatibility.txt │ │ │ ├── netease.txt │ │ │ ├── performant_optifine_incompatibility.txt │ │ │ ├── shadersmodcore.txt │ │ │ ├── tconstruct.txt │ │ │ ├── thaumcraft.txt │ │ │ ├── twilightforest.txt │ │ │ ├── twilightforest_optifine_incompatibility.txt │ │ │ └── wizardry.txt │ │ ├── mod_resolution0.txt │ │ ├── need_jdk11.txt │ │ ├── need_jdk112.txt │ │ ├── need_jdk113.txt │ │ ├── night_config_fixes.txt │ │ ├── no_class_def_found_error.txt │ │ ├── no_class_def_found_error2.txt │ │ ├── out_of_memory.txt │ │ ├── processing_of_javaagent_failed.txt │ │ ├── resourcepack_resolution.txt │ │ ├── rtss_forest_sodium.txt │ │ ├── security.txt │ │ ├── splashscreen.txt │ │ ├── too_old_java.txt │ │ └── too_old_java2.txt │ ├── game-json/ │ │ └── tlauncher.json │ ├── logs/ │ │ ├── bootstrap.txt │ │ ├── crash-report.txt │ │ ├── duplicated_mod.txt │ │ ├── fabric-minecraft.txt │ │ ├── fabric-mod-conflict.txt │ │ ├── fabric-mod-missing.txt │ │ ├── fabric-version-0.12.txt │ │ ├── fabric_warnings.txt │ │ ├── fabric_warnings2.txt │ │ ├── fabric_warnings3.txt │ │ ├── failed_to_load_a_library.txt │ │ ├── forge_found_duplicate_mods.txt │ │ ├── forge_repeat_installation.txt │ │ ├── forge_repeat_installation2.txt │ │ ├── forgemod_resolution.txt │ │ ├── graphics_driver.txt │ │ ├── incomplete_forge_installation.txt │ │ ├── incomplete_forge_installation2.txt │ │ ├── incomplete_forge_installation3.txt │ │ ├── incomplete_forge_installation4.txt │ │ ├── incomplete_forge_installation5.txt │ │ ├── incomplete_forge_installation6.txt │ │ ├── incomplete_forge_installation7.txt │ │ ├── install_mixinbootstrap.txt │ │ ├── jade_forest_optifine.txt │ │ ├── java9.txt │ │ ├── java_version_is_too_high.txt │ │ ├── jvm_32bit.txt │ │ ├── jvm_32bit2.txt │ │ ├── macos_failed_to_find_service_port_for_display.txt │ │ ├── memory_exceeded.txt │ │ ├── mixin_apply_mod_failed.txt │ │ ├── mod_name.txt │ │ ├── mod_resolution.txt │ │ ├── mod_resolution_collection.txt │ │ ├── openj9-unsupported_charset.txt │ │ ├── openj9.txt │ │ ├── optifine_is_not_compatible_with_forge.txt │ │ ├── optifine_is_not_compatible_with_forge2.txt │ │ ├── optifine_is_not_compatible_with_forge3.txt │ │ ├── optifine_is_not_compatible_with_forge4.txt │ │ ├── optifine_is_not_compatible_with_forge5.txt │ │ ├── optifine_is_not_compatible_with_forge6.txt │ │ ├── optifine_repeat_installation.txt │ │ ├── out_of_memory.txt │ │ ├── out_of_memory2.txt │ │ ├── shaders_mod.txt │ │ ├── too_old_java.txt │ │ └── unsatisfied_link_error.txt │ └── schematics/ │ └── test.litematic ├── LICENSE ├── build.gradle.kts ├── buildSrc/ │ ├── build.gradle.kts │ ├── settings.gradle.kts │ └── src/ │ └── main/ │ ├── java/ │ │ └── org/ │ │ └── jackhuang/ │ │ └── hmcl/ │ │ └── gradle/ │ │ ├── TerracottaConfigUpgradeTask.java │ │ ├── ci/ │ │ │ ├── CheckUpdate.java │ │ │ ├── GitHubActionUtils.java │ │ │ └── JenkinsUtils.java │ │ ├── docs/ │ │ │ ├── Document.java │ │ │ ├── DocumentFileTree.java │ │ │ ├── DocumentLocale.java │ │ │ ├── LocalizedDocument.java │ │ │ ├── MacroProcessor.java │ │ │ └── UpdateDocuments.java │ │ ├── javafx/ │ │ │ ├── JavaFXPlatform.java │ │ │ ├── JavaFXUtils.java │ │ │ └── JavaFXVersionType.java │ │ ├── l10n/ │ │ │ ├── CheckTranslations.java │ │ │ ├── CreateLanguageList.java │ │ │ ├── CreateLocaleNamesResourceBundle.java │ │ │ ├── LocalizationUtils.java │ │ │ ├── ParseLanguageSubtagRegistry.java │ │ │ └── UpsideDownTranslate.java │ │ ├── mod/ │ │ │ └── ParseModDataTask.java │ │ └── utils/ │ │ └── PropertiesUtils.java │ └── resources/ │ └── org/ │ └── jackhuang/ │ └── hmcl/ │ └── gradle/ │ └── l10n/ │ ├── LocaleNamesOverride.properties │ ├── LocaleNamesOverride_lzh.properties │ ├── LocaleNamesOverride_zh.properties │ └── LocaleNamesOverride_zh_Hant.properties ├── config/ │ ├── checkstyle/ │ │ └── checkstyle.xml │ ├── jenkins/ │ │ ├── config-jenkins.sh │ │ ├── dev/ │ │ │ └── Jenkinsfile │ │ └── stable/ │ │ └── Jenkinsfile │ └── project.properties ├── docs/ │ ├── Contributing.md │ ├── Contributing_zh.md │ ├── Contributing_zh_Hant.md │ ├── Localization.md │ ├── Localization_zh.md │ ├── Localization_zh_Hant.md │ ├── PLATFORM.md │ ├── PLATFORM_zh.md │ ├── PLATFORM_zh_Hant.md │ ├── README.md │ ├── README_en_Qabs.md │ ├── README_es.md │ ├── README_ja.md │ ├── README_lzh.md │ ├── README_ru.md │ ├── README_uk.md │ ├── README_zh.md │ ├── README_zh_Hant.md │ ├── ReleaseSchedule.md │ ├── ReleaseSchedule_zh.md │ └── ReleaseSchedule_zh_Hant.md ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lib/ │ └── JFoenix.jar ├── minecraft/ │ └── libraries/ │ ├── HMCLMultiMCBootstrap/ │ │ ├── build.gradle.kts │ │ └── src/ │ │ └── main/ │ │ └── java/ │ │ └── org/ │ │ └── jackhuang/ │ │ └── hmcl/ │ │ └── HMCLMultiMCBootstrap.java │ └── HMCLTransformerDiscoveryService/ │ ├── build.gradle.kts │ ├── lib/ │ │ └── modlauncher-4.1.0.jar │ └── src/ │ └── main/ │ ├── java/ │ │ └── org/ │ │ └── jackhuang/ │ │ └── hmcl/ │ │ └── HMCLTransformerDiscoveryService.java │ └── resources/ │ └── META-INF/ │ └── services/ │ └── cpw.mods.modlauncher.serviceapi.ITransformerDiscoveryService └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cnb/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: QQ 群 url: https://docs.hmcl.net/groups.html about: Hello Minecraft! Launcher 的官方 QQ 交流群。 - name: Discord 服务器 url: https://discord.gg/jVvC7HfM6U about: Hello Minecraft! Launcher 的官方 Discord 服务器。 ================================================ FILE: .editorconfig ================================================ [*] charset = utf-8 end_of_line = lf indent_size = 4 indent_style = space insert_final_newline = false max_line_length = 120 tab_width = 4 ij_continuation_indent_size = 8 ij_formatter_off_tag = @formatter:off ij_formatter_on_tag = @formatter:on ij_formatter_tags_enabled = true ij_smart_tabs = false ij_visual_guides = none ij_wrap_on_typing = false [*.css] ij_css_align_closing_brace_with_properties = false ij_css_blank_lines_around_nested_selector = 1 ij_css_blank_lines_between_blocks = 1 ij_css_brace_placement = end_of_line ij_css_enforce_quotes_on_format = false ij_css_hex_color_long_format = false ij_css_hex_color_lower_case = false ij_css_hex_color_short_format = false ij_css_hex_color_upper_case = false ij_css_keep_blank_lines_in_code = 2 ij_css_keep_indents_on_empty_lines = false ij_css_keep_single_line_blocks = false ij_css_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow ij_css_space_after_colon = true ij_css_space_before_opening_brace = true ij_css_use_double_quotes = true ij_css_value_alignment = do_not_align [*.feature] indent_size = 2 ij_gherkin_keep_indents_on_empty_lines = false [*.gsp] ij_gsp_keep_indents_on_empty_lines = false [*.haml] indent_size = 2 ij_haml_keep_indents_on_empty_lines = false [*.java] ij_java_align_consecutive_assignments = false ij_java_align_consecutive_variable_declarations = false ij_java_align_group_field_declarations = false ij_java_align_multiline_annotation_parameters = false ij_java_align_multiline_array_initializer_expression = false ij_java_align_multiline_assignment = false ij_java_align_multiline_binary_operation = false ij_java_align_multiline_chained_methods = false ij_java_align_multiline_extends_list = false ij_java_align_multiline_for = true ij_java_align_multiline_method_parentheses = false ij_java_align_multiline_parameters = true ij_java_align_multiline_parameters_in_calls = false ij_java_align_multiline_parenthesized_expression = false ij_java_align_multiline_records = true ij_java_align_multiline_resources = true ij_java_align_multiline_ternary_operation = false ij_java_align_multiline_text_blocks = false ij_java_align_multiline_throws_list = false ij_java_align_subsequent_simple_methods = false ij_java_align_throws_keyword = false ij_java_annotation_parameter_wrap = off ij_java_array_initializer_new_line_after_left_brace = false ij_java_array_initializer_right_brace_on_new_line = false ij_java_array_initializer_wrap = off ij_java_assert_statement_colon_on_next_line = false ij_java_assert_statement_wrap = off ij_java_assignment_wrap = off ij_java_binary_operation_sign_on_next_line = false ij_java_binary_operation_wrap = off ij_java_blank_lines_after_anonymous_class_header = 0 ij_java_blank_lines_after_class_header = 0 ij_java_blank_lines_after_imports = 1 ij_java_blank_lines_after_package = 1 ij_java_blank_lines_around_class = 1 ij_java_blank_lines_around_field = 0 ij_java_blank_lines_around_field_in_interface = 0 ij_java_blank_lines_around_initializer = 1 ij_java_blank_lines_around_method = 1 ij_java_blank_lines_around_method_in_interface = 1 ij_java_blank_lines_before_class_end = 0 ij_java_blank_lines_before_imports = 1 ij_java_blank_lines_before_method_body = 0 ij_java_blank_lines_before_package = 0 ij_java_block_brace_style = end_of_line ij_java_block_comment_at_first_column = true ij_java_builder_methods = none ij_java_call_parameters_new_line_after_left_paren = false ij_java_call_parameters_right_paren_on_new_line = false ij_java_call_parameters_wrap = off ij_java_case_statement_on_separate_line = true ij_java_catch_on_new_line = false ij_java_class_annotation_wrap = split_into_lines ij_java_class_brace_style = end_of_line ij_java_class_count_to_use_import_on_demand = 5 ij_java_class_names_in_javadoc = 1 ij_java_do_not_indent_top_level_class_members = false ij_java_do_not_wrap_after_single_annotation = false ij_java_do_while_brace_force = never ij_java_doc_add_blank_line_after_description = true ij_java_doc_add_blank_line_after_param_comments = false ij_java_doc_add_blank_line_after_return = false ij_java_doc_add_p_tag_on_empty_lines = true ij_java_doc_align_exception_comments = true ij_java_doc_align_param_comments = true ij_java_doc_do_not_wrap_if_one_line = false ij_java_doc_enable_formatting = true ij_java_doc_enable_leading_asterisks = true ij_java_doc_indent_on_continuation = false ij_java_doc_keep_empty_lines = true ij_java_doc_keep_empty_parameter_tag = true ij_java_doc_keep_empty_return_tag = true ij_java_doc_keep_empty_throws_tag = true ij_java_doc_keep_invalid_tags = true ij_java_doc_param_description_on_new_line = false ij_java_doc_preserve_line_breaks = false ij_java_doc_use_throws_not_exception_tag = true ij_java_else_on_new_line = false ij_java_entity_dd_suffix = EJB ij_java_entity_eb_suffix = Bean ij_java_entity_hi_suffix = Home ij_java_entity_lhi_prefix = Local ij_java_entity_lhi_suffix = Home ij_java_entity_li_prefix = Local ij_java_entity_pk_class = java.lang.String ij_java_entity_vo_suffix = VO ij_java_enum_constants_wrap = off ij_java_extends_keyword_wrap = off ij_java_extends_list_wrap = off ij_java_field_annotation_wrap = split_into_lines ij_java_finally_on_new_line = false ij_java_for_brace_force = never ij_java_for_statement_new_line_after_left_paren = false ij_java_for_statement_right_paren_on_new_line = false ij_java_for_statement_wrap = off ij_java_generate_final_locals = false ij_java_generate_final_parameters = false ij_java_if_brace_force = never ij_java_imports_layout = *,|,javax.**,java.**,|,$* ij_java_indent_case_from_switch = true ij_java_insert_inner_class_imports = false ij_java_insert_override_annotation = true ij_java_keep_blank_lines_before_right_brace = 2 ij_java_keep_blank_lines_between_package_declaration_and_header = 2 ij_java_keep_blank_lines_in_code = 2 ij_java_keep_blank_lines_in_declarations = 2 ij_java_keep_builder_methods_indents = false ij_java_keep_control_statement_in_one_line = true ij_java_keep_first_column_comment = true ij_java_keep_indents_on_empty_lines = false ij_java_keep_line_breaks = true ij_java_keep_multiple_expressions_in_one_line = false ij_java_keep_simple_blocks_in_one_line = false ij_java_keep_simple_classes_in_one_line = false ij_java_keep_simple_lambdas_in_one_line = false ij_java_keep_simple_methods_in_one_line = false ij_java_label_indent_absolute = false ij_java_label_indent_size = 0 ij_java_lambda_brace_style = end_of_line ij_java_layout_static_imports_separately = true ij_java_line_comment_add_space = false ij_java_line_comment_at_first_column = true ij_java_message_dd_suffix = EJB ij_java_message_eb_suffix = Bean ij_java_method_annotation_wrap = split_into_lines ij_java_method_brace_style = end_of_line ij_java_method_call_chain_wrap = off ij_java_method_parameters_new_line_after_left_paren = false ij_java_method_parameters_right_paren_on_new_line = false ij_java_method_parameters_wrap = off ij_java_modifier_list_wrap = false ij_java_names_count_to_use_import_on_demand = 3 ij_java_new_line_after_lparen_in_record_header = false ij_java_packages_to_use_import_on_demand = java.awt.*,javax.swing.* ij_java_parameter_annotation_wrap = off ij_java_parentheses_expression_new_line_after_left_paren = false ij_java_parentheses_expression_right_paren_on_new_line = false ij_java_place_assignment_sign_on_next_line = false ij_java_prefer_longer_names = true ij_java_prefer_parameters_wrap = false ij_java_record_components_wrap = normal ij_java_repeat_synchronized = true ij_java_replace_instanceof_and_cast = false ij_java_replace_null_check = true ij_java_replace_sum_lambda_with_method_ref = true ij_java_resource_list_new_line_after_left_paren = false ij_java_resource_list_right_paren_on_new_line = false ij_java_resource_list_wrap = off ij_java_rparen_on_new_line_in_record_header = false ij_java_session_dd_suffix = EJB ij_java_session_eb_suffix = Bean ij_java_session_hi_suffix = Home ij_java_session_lhi_prefix = Local ij_java_session_lhi_suffix = Home ij_java_session_li_prefix = Local ij_java_session_si_suffix = Service ij_java_space_after_closing_angle_bracket_in_type_argument = false ij_java_space_after_colon = true ij_java_space_after_comma = true ij_java_space_after_comma_in_type_arguments = true ij_java_space_after_for_semicolon = true ij_java_space_after_quest = true ij_java_space_after_type_cast = true ij_java_space_before_annotation_array_initializer_left_brace = false ij_java_space_before_annotation_parameter_list = false ij_java_space_before_array_initializer_left_brace = false ij_java_space_before_catch_keyword = true ij_java_space_before_catch_left_brace = true ij_java_space_before_catch_parentheses = true ij_java_space_before_class_left_brace = true ij_java_space_before_colon = true ij_java_space_before_colon_in_foreach = true ij_java_space_before_comma = false ij_java_space_before_do_left_brace = true ij_java_space_before_else_keyword = true ij_java_space_before_else_left_brace = true ij_java_space_before_finally_keyword = true ij_java_space_before_finally_left_brace = true ij_java_space_before_for_left_brace = true ij_java_space_before_for_parentheses = true ij_java_space_before_for_semicolon = false ij_java_space_before_if_left_brace = true ij_java_space_before_if_parentheses = true ij_java_space_before_method_call_parentheses = false ij_java_space_before_method_left_brace = true ij_java_space_before_method_parentheses = false ij_java_space_before_opening_angle_bracket_in_type_parameter = false ij_java_space_before_quest = true ij_java_space_before_switch_left_brace = true ij_java_space_before_switch_parentheses = true ij_java_space_before_synchronized_left_brace = true ij_java_space_before_synchronized_parentheses = true ij_java_space_before_try_left_brace = true ij_java_space_before_try_parentheses = true ij_java_space_before_type_parameter_list = false ij_java_space_before_while_keyword = true ij_java_space_before_while_left_brace = true ij_java_space_before_while_parentheses = true ij_java_space_inside_one_line_enum_braces = false ij_java_space_within_empty_array_initializer_braces = false ij_java_space_within_empty_method_call_parentheses = false ij_java_space_within_empty_method_parentheses = false ij_java_spaces_around_additive_operators = true ij_java_spaces_around_assignment_operators = true ij_java_spaces_around_bitwise_operators = true ij_java_spaces_around_equality_operators = true ij_java_spaces_around_lambda_arrow = true ij_java_spaces_around_logical_operators = true ij_java_spaces_around_method_ref_dbl_colon = false ij_java_spaces_around_multiplicative_operators = true ij_java_spaces_around_relational_operators = true ij_java_spaces_around_shift_operators = true ij_java_spaces_around_type_bounds_in_type_parameters = true ij_java_spaces_around_unary_operator = false ij_java_spaces_within_angle_brackets = false ij_java_spaces_within_annotation_parentheses = false ij_java_spaces_within_array_initializer_braces = false ij_java_spaces_within_braces = false ij_java_spaces_within_brackets = false ij_java_spaces_within_cast_parentheses = false ij_java_spaces_within_catch_parentheses = false ij_java_spaces_within_for_parentheses = false ij_java_spaces_within_if_parentheses = false ij_java_spaces_within_method_call_parentheses = false ij_java_spaces_within_method_parentheses = false ij_java_spaces_within_parentheses = false ij_java_spaces_within_record_header = false ij_java_spaces_within_switch_parentheses = false ij_java_spaces_within_synchronized_parentheses = false ij_java_spaces_within_try_parentheses = false ij_java_spaces_within_while_parentheses = false ij_java_special_else_if_treatment = true ij_java_subclass_name_suffix = Impl ij_java_ternary_operation_signs_on_next_line = false ij_java_ternary_operation_wrap = off ij_java_test_name_suffix = Test ij_java_throws_keyword_wrap = off ij_java_throws_list_wrap = off ij_java_use_external_annotations = false ij_java_use_fq_class_names = false ij_java_use_relative_indents = false ij_java_use_single_class_imports = true ij_java_variable_annotation_wrap = off ij_java_visibility = public ij_java_while_brace_force = never ij_java_while_on_new_line = false ij_java_wrap_comments = false ij_java_wrap_first_method_in_call_chain = false ij_java_wrap_long_lines = false [*.less] indent_size = 2 ij_less_align_closing_brace_with_properties = false ij_less_blank_lines_around_nested_selector = 1 ij_less_blank_lines_between_blocks = 1 ij_less_brace_placement = 0 ij_less_enforce_quotes_on_format = false ij_less_hex_color_long_format = false ij_less_hex_color_lower_case = false ij_less_hex_color_short_format = false ij_less_hex_color_upper_case = false ij_less_keep_blank_lines_in_code = 2 ij_less_keep_indents_on_empty_lines = false ij_less_keep_single_line_blocks = false ij_less_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow ij_less_space_after_colon = true ij_less_space_before_opening_brace = true ij_less_use_double_quotes = true ij_less_value_alignment = 0 [*.proto] indent_size = 2 tab_width = 2 ij_continuation_indent_size = 4 ij_protobuf_keep_blank_lines_in_code = 2 ij_protobuf_keep_indents_on_empty_lines = false ij_protobuf_keep_line_breaks = true ij_protobuf_space_after_comma = true ij_protobuf_space_before_comma = false ij_protobuf_spaces_around_assignment_operators = true ij_protobuf_spaces_within_braces = false ij_protobuf_spaces_within_brackets = false [*.sass] indent_size = 2 ij_sass_align_closing_brace_with_properties = false ij_sass_blank_lines_around_nested_selector = 1 ij_sass_blank_lines_between_blocks = 1 ij_sass_brace_placement = 0 ij_sass_enforce_quotes_on_format = false ij_sass_hex_color_long_format = false ij_sass_hex_color_lower_case = false ij_sass_hex_color_short_format = false ij_sass_hex_color_upper_case = false ij_sass_keep_blank_lines_in_code = 2 ij_sass_keep_indents_on_empty_lines = false ij_sass_keep_single_line_blocks = false ij_sass_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow ij_sass_space_after_colon = true ij_sass_space_before_opening_brace = true ij_sass_use_double_quotes = true ij_sass_value_alignment = 0 [*.scss] indent_size = 2 ij_scss_align_closing_brace_with_properties = false ij_scss_blank_lines_around_nested_selector = 1 ij_scss_blank_lines_between_blocks = 1 ij_scss_brace_placement = 0 ij_scss_enforce_quotes_on_format = false ij_scss_hex_color_long_format = false ij_scss_hex_color_lower_case = false ij_scss_hex_color_short_format = false ij_scss_hex_color_upper_case = false ij_scss_keep_blank_lines_in_code = 2 ij_scss_keep_indents_on_empty_lines = false ij_scss_keep_single_line_blocks = false ij_scss_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow ij_scss_space_after_colon = true ij_scss_space_before_opening_brace = true ij_scss_use_double_quotes = true ij_scss_value_alignment = 0 [*.styl] indent_size = 2 ij_stylus_align_closing_brace_with_properties = false ij_stylus_blank_lines_around_nested_selector = 1 ij_stylus_blank_lines_between_blocks = 1 ij_stylus_brace_placement = 0 ij_stylus_enforce_quotes_on_format = false ij_stylus_hex_color_long_format = false ij_stylus_hex_color_lower_case = false ij_stylus_hex_color_short_format = false ij_stylus_hex_color_upper_case = false ij_stylus_keep_blank_lines_in_code = 2 ij_stylus_keep_indents_on_empty_lines = false ij_stylus_keep_single_line_blocks = false ij_stylus_properties_order = font,font-family,font-size,font-weight,font-style,font-variant,font-size-adjust,font-stretch,line-height,position,z-index,top,right,bottom,left,display,visibility,float,clear,overflow,overflow-x,overflow-y,clip,zoom,align-content,align-items,align-self,flex,flex-flow,flex-basis,flex-direction,flex-grow,flex-shrink,flex-wrap,justify-content,order,box-sizing,width,min-width,max-width,height,min-height,max-height,margin,margin-top,margin-right,margin-bottom,margin-left,padding,padding-top,padding-right,padding-bottom,padding-left,table-layout,empty-cells,caption-side,border-spacing,border-collapse,list-style,list-style-position,list-style-type,list-style-image,content,quotes,counter-reset,counter-increment,resize,cursor,user-select,nav-index,nav-up,nav-right,nav-down,nav-left,transition,transition-delay,transition-timing-function,transition-duration,transition-property,transform,transform-origin,animation,animation-name,animation-duration,animation-play-state,animation-timing-function,animation-delay,animation-iteration-count,animation-direction,text-align,text-align-last,vertical-align,white-space,text-decoration,text-emphasis,text-emphasis-color,text-emphasis-style,text-emphasis-position,text-indent,text-justify,letter-spacing,word-spacing,text-outline,text-transform,text-wrap,text-overflow,text-overflow-ellipsis,text-overflow-mode,word-wrap,word-break,tab-size,hyphens,pointer-events,opacity,color,border,border-width,border-style,border-color,border-top,border-top-width,border-top-style,border-top-color,border-right,border-right-width,border-right-style,border-right-color,border-bottom,border-bottom-width,border-bottom-style,border-bottom-color,border-left,border-left-width,border-left-style,border-left-color,border-radius,border-top-left-radius,border-top-right-radius,border-bottom-right-radius,border-bottom-left-radius,border-image,border-image-source,border-image-slice,border-image-width,border-image-outset,border-image-repeat,outline,outline-width,outline-style,outline-color,outline-offset,background,background-color,background-image,background-repeat,background-attachment,background-position,background-position-x,background-position-y,background-clip,background-origin,background-size,box-decoration-break,box-shadow,text-shadow ij_stylus_space_after_colon = true ij_stylus_space_before_opening_brace = true ij_stylus_use_double_quotes = true ij_stylus_value_alignment = 0 [.editorconfig] ij_editorconfig_align_group_field_declarations = false ij_editorconfig_space_after_colon = false ij_editorconfig_space_after_comma = true ij_editorconfig_space_before_colon = false ij_editorconfig_space_before_comma = false ij_editorconfig_spaces_around_assignment_operators = true [{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.pom,*.rng,*.tld,*.wadl,*.wsdd,*.wsdl,*.xjb,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] ij_xml_align_attributes = true ij_xml_align_text = false ij_xml_attribute_wrap = normal ij_xml_block_comment_at_first_column = true ij_xml_keep_blank_lines = 2 ij_xml_keep_indents_on_empty_lines = false ij_xml_keep_line_breaks = true ij_xml_keep_line_breaks_in_text = true ij_xml_keep_whitespaces = false ij_xml_keep_whitespaces_around_cdata = preserve ij_xml_keep_whitespaces_inside_cdata = false ij_xml_line_comment_at_first_column = true ij_xml_space_after_tag_name = false ij_xml_space_around_equals_in_attribute = false ij_xml_space_inside_empty_tag = false ij_xml_text_wrap = normal ij_xml_use_custom_settings = false [{*.ats,*.ts}] ij_continuation_indent_size = 4 ij_typescript_align_imports = false ij_typescript_align_multiline_array_initializer_expression = false ij_typescript_align_multiline_binary_operation = false ij_typescript_align_multiline_chained_methods = false ij_typescript_align_multiline_extends_list = false ij_typescript_align_multiline_for = true ij_typescript_align_multiline_parameters = true ij_typescript_align_multiline_parameters_in_calls = false ij_typescript_align_multiline_ternary_operation = false ij_typescript_align_object_properties = 0 ij_typescript_align_union_types = false ij_typescript_align_var_statements = 0 ij_typescript_array_initializer_new_line_after_left_brace = false ij_typescript_array_initializer_right_brace_on_new_line = false ij_typescript_array_initializer_wrap = off ij_typescript_assignment_wrap = off ij_typescript_binary_operation_sign_on_next_line = false ij_typescript_binary_operation_wrap = off ij_typescript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** ij_typescript_blank_lines_after_imports = 1 ij_typescript_blank_lines_around_class = 1 ij_typescript_blank_lines_around_field = 0 ij_typescript_blank_lines_around_field_in_interface = 0 ij_typescript_blank_lines_around_function = 1 ij_typescript_blank_lines_around_method = 1 ij_typescript_blank_lines_around_method_in_interface = 1 ij_typescript_block_brace_style = end_of_line ij_typescript_call_parameters_new_line_after_left_paren = false ij_typescript_call_parameters_right_paren_on_new_line = false ij_typescript_call_parameters_wrap = off ij_typescript_catch_on_new_line = false ij_typescript_chained_call_dot_on_new_line = true ij_typescript_class_brace_style = end_of_line ij_typescript_comma_on_new_line = false ij_typescript_do_while_brace_force = never ij_typescript_else_on_new_line = false ij_typescript_enforce_trailing_comma = keep ij_typescript_extends_keyword_wrap = off ij_typescript_extends_list_wrap = off ij_typescript_field_prefix = _ ij_typescript_file_name_style = relaxed ij_typescript_finally_on_new_line = false ij_typescript_for_brace_force = never ij_typescript_for_statement_new_line_after_left_paren = false ij_typescript_for_statement_right_paren_on_new_line = false ij_typescript_for_statement_wrap = off ij_typescript_force_quote_style = false ij_typescript_force_semicolon_style = false ij_typescript_function_expression_brace_style = end_of_line ij_typescript_if_brace_force = never ij_typescript_import_merge_members = global ij_typescript_import_prefer_absolute_path = global ij_typescript_import_sort_members = true ij_typescript_import_sort_module_name = false ij_typescript_import_use_node_resolution = true ij_typescript_imports_wrap = on_every_item ij_typescript_indent_case_from_switch = true ij_typescript_indent_chained_calls = true ij_typescript_indent_package_children = 0 ij_typescript_jsdoc_include_types = false ij_typescript_jsx_attribute_value = braces ij_typescript_keep_blank_lines_in_code = 2 ij_typescript_keep_first_column_comment = true ij_typescript_keep_indents_on_empty_lines = false ij_typescript_keep_line_breaks = true ij_typescript_keep_simple_blocks_in_one_line = false ij_typescript_keep_simple_methods_in_one_line = false ij_typescript_line_comment_add_space = true ij_typescript_line_comment_at_first_column = false ij_typescript_method_brace_style = end_of_line ij_typescript_method_call_chain_wrap = off ij_typescript_method_parameters_new_line_after_left_paren = false ij_typescript_method_parameters_right_paren_on_new_line = false ij_typescript_method_parameters_wrap = off ij_typescript_object_literal_wrap = on_every_item ij_typescript_parentheses_expression_new_line_after_left_paren = false ij_typescript_parentheses_expression_right_paren_on_new_line = false ij_typescript_place_assignment_sign_on_next_line = false ij_typescript_prefer_as_type_cast = false ij_typescript_prefer_explicit_types_function_expression_returns = false ij_typescript_prefer_explicit_types_function_returns = false ij_typescript_prefer_explicit_types_vars_fields = false ij_typescript_prefer_parameters_wrap = false ij_typescript_reformat_c_style_comments = false ij_typescript_space_after_colon = true ij_typescript_space_after_comma = true ij_typescript_space_after_dots_in_rest_parameter = false ij_typescript_space_after_generator_mult = true ij_typescript_space_after_property_colon = true ij_typescript_space_after_quest = true ij_typescript_space_after_type_colon = true ij_typescript_space_after_unary_not = false ij_typescript_space_before_async_arrow_lparen = true ij_typescript_space_before_catch_keyword = true ij_typescript_space_before_catch_left_brace = true ij_typescript_space_before_catch_parentheses = true ij_typescript_space_before_class_lbrace = true ij_typescript_space_before_class_left_brace = true ij_typescript_space_before_colon = true ij_typescript_space_before_comma = false ij_typescript_space_before_do_left_brace = true ij_typescript_space_before_else_keyword = true ij_typescript_space_before_else_left_brace = true ij_typescript_space_before_finally_keyword = true ij_typescript_space_before_finally_left_brace = true ij_typescript_space_before_for_left_brace = true ij_typescript_space_before_for_parentheses = true ij_typescript_space_before_for_semicolon = false ij_typescript_space_before_function_left_parenth = true ij_typescript_space_before_generator_mult = false ij_typescript_space_before_if_left_brace = true ij_typescript_space_before_if_parentheses = true ij_typescript_space_before_method_call_parentheses = false ij_typescript_space_before_method_left_brace = true ij_typescript_space_before_method_parentheses = false ij_typescript_space_before_property_colon = false ij_typescript_space_before_quest = true ij_typescript_space_before_switch_left_brace = true ij_typescript_space_before_switch_parentheses = true ij_typescript_space_before_try_left_brace = true ij_typescript_space_before_type_colon = false ij_typescript_space_before_unary_not = false ij_typescript_space_before_while_keyword = true ij_typescript_space_before_while_left_brace = true ij_typescript_space_before_while_parentheses = true ij_typescript_spaces_around_additive_operators = true ij_typescript_spaces_around_arrow_function_operator = true ij_typescript_spaces_around_assignment_operators = true ij_typescript_spaces_around_bitwise_operators = true ij_typescript_spaces_around_equality_operators = true ij_typescript_spaces_around_logical_operators = true ij_typescript_spaces_around_multiplicative_operators = true ij_typescript_spaces_around_relational_operators = true ij_typescript_spaces_around_shift_operators = true ij_typescript_spaces_around_unary_operator = false ij_typescript_spaces_within_array_initializer_brackets = false ij_typescript_spaces_within_brackets = false ij_typescript_spaces_within_catch_parentheses = false ij_typescript_spaces_within_for_parentheses = false ij_typescript_spaces_within_if_parentheses = false ij_typescript_spaces_within_imports = false ij_typescript_spaces_within_interpolation_expressions = false ij_typescript_spaces_within_method_call_parentheses = false ij_typescript_spaces_within_method_parentheses = false ij_typescript_spaces_within_object_literal_braces = false ij_typescript_spaces_within_object_type_braces = true ij_typescript_spaces_within_parentheses = false ij_typescript_spaces_within_switch_parentheses = false ij_typescript_spaces_within_type_assertion = false ij_typescript_spaces_within_union_types = true ij_typescript_spaces_within_while_parentheses = false ij_typescript_special_else_if_treatment = true ij_typescript_ternary_operation_signs_on_next_line = false ij_typescript_ternary_operation_wrap = off ij_typescript_union_types_wrap = on_every_item ij_typescript_use_chained_calls_group_indents = false ij_typescript_use_double_quotes = true ij_typescript_use_explicit_js_extension = global ij_typescript_use_path_mapping = always ij_typescript_use_public_modifier = false ij_typescript_use_semicolon_after_statement = true ij_typescript_var_declaration_wrap = normal ij_typescript_while_brace_force = never ij_typescript_while_on_new_line = false ij_typescript_wrap_comments = false [{*.bash,*.sh,*.zsh}] indent_size = 2 tab_width = 2 ij_shell_binary_ops_start_line = false ij_shell_keep_column_alignment_padding = false ij_shell_minify_program = false ij_shell_redirect_followed_by_space = false ij_shell_switch_cases_indented = false ij_shell_use_unix_line_separator = true [{*.cjs,*.js}] ij_continuation_indent_size = 4 ij_javascript_align_imports = false ij_javascript_align_multiline_array_initializer_expression = false ij_javascript_align_multiline_binary_operation = false ij_javascript_align_multiline_chained_methods = false ij_javascript_align_multiline_extends_list = false ij_javascript_align_multiline_for = true ij_javascript_align_multiline_parameters = true ij_javascript_align_multiline_parameters_in_calls = false ij_javascript_align_multiline_ternary_operation = false ij_javascript_align_object_properties = 0 ij_javascript_align_union_types = false ij_javascript_align_var_statements = 0 ij_javascript_array_initializer_new_line_after_left_brace = false ij_javascript_array_initializer_right_brace_on_new_line = false ij_javascript_array_initializer_wrap = off ij_javascript_assignment_wrap = off ij_javascript_binary_operation_sign_on_next_line = false ij_javascript_binary_operation_wrap = off ij_javascript_blacklist_imports = rxjs/Rx,node_modules/**,**/node_modules/**,@angular/material,@angular/material/typings/** ij_javascript_blank_lines_after_imports = 1 ij_javascript_blank_lines_around_class = 1 ij_javascript_blank_lines_around_field = 0 ij_javascript_blank_lines_around_function = 1 ij_javascript_blank_lines_around_method = 1 ij_javascript_block_brace_style = end_of_line ij_javascript_call_parameters_new_line_after_left_paren = false ij_javascript_call_parameters_right_paren_on_new_line = false ij_javascript_call_parameters_wrap = off ij_javascript_catch_on_new_line = false ij_javascript_chained_call_dot_on_new_line = true ij_javascript_class_brace_style = end_of_line ij_javascript_comma_on_new_line = false ij_javascript_do_while_brace_force = never ij_javascript_else_on_new_line = false ij_javascript_enforce_trailing_comma = keep ij_javascript_extends_keyword_wrap = off ij_javascript_extends_list_wrap = off ij_javascript_field_prefix = _ ij_javascript_file_name_style = relaxed ij_javascript_finally_on_new_line = false ij_javascript_for_brace_force = never ij_javascript_for_statement_new_line_after_left_paren = false ij_javascript_for_statement_right_paren_on_new_line = false ij_javascript_for_statement_wrap = off ij_javascript_force_quote_style = false ij_javascript_force_semicolon_style = false ij_javascript_function_expression_brace_style = end_of_line ij_javascript_if_brace_force = never ij_javascript_import_merge_members = global ij_javascript_import_prefer_absolute_path = global ij_javascript_import_sort_members = true ij_javascript_import_sort_module_name = false ij_javascript_import_use_node_resolution = true ij_javascript_imports_wrap = on_every_item ij_javascript_indent_case_from_switch = true ij_javascript_indent_chained_calls = true ij_javascript_indent_package_children = 0 ij_javascript_jsx_attribute_value = braces ij_javascript_keep_blank_lines_in_code = 2 ij_javascript_keep_first_column_comment = true ij_javascript_keep_indents_on_empty_lines = false ij_javascript_keep_line_breaks = true ij_javascript_keep_simple_blocks_in_one_line = false ij_javascript_keep_simple_methods_in_one_line = false ij_javascript_line_comment_add_space = true ij_javascript_line_comment_at_first_column = false ij_javascript_method_brace_style = end_of_line ij_javascript_method_call_chain_wrap = off ij_javascript_method_parameters_new_line_after_left_paren = false ij_javascript_method_parameters_right_paren_on_new_line = false ij_javascript_method_parameters_wrap = off ij_javascript_object_literal_wrap = on_every_item ij_javascript_parentheses_expression_new_line_after_left_paren = false ij_javascript_parentheses_expression_right_paren_on_new_line = false ij_javascript_place_assignment_sign_on_next_line = false ij_javascript_prefer_as_type_cast = false ij_javascript_prefer_explicit_types_function_expression_returns = false ij_javascript_prefer_explicit_types_function_returns = false ij_javascript_prefer_explicit_types_vars_fields = false ij_javascript_prefer_parameters_wrap = false ij_javascript_reformat_c_style_comments = false ij_javascript_space_after_colon = true ij_javascript_space_after_comma = true ij_javascript_space_after_dots_in_rest_parameter = false ij_javascript_space_after_generator_mult = true ij_javascript_space_after_property_colon = true ij_javascript_space_after_quest = true ij_javascript_space_after_type_colon = true ij_javascript_space_after_unary_not = false ij_javascript_space_before_async_arrow_lparen = true ij_javascript_space_before_catch_keyword = true ij_javascript_space_before_catch_left_brace = true ij_javascript_space_before_catch_parentheses = true ij_javascript_space_before_class_lbrace = true ij_javascript_space_before_class_left_brace = true ij_javascript_space_before_colon = true ij_javascript_space_before_comma = false ij_javascript_space_before_do_left_brace = true ij_javascript_space_before_else_keyword = true ij_javascript_space_before_else_left_brace = true ij_javascript_space_before_finally_keyword = true ij_javascript_space_before_finally_left_brace = true ij_javascript_space_before_for_left_brace = true ij_javascript_space_before_for_parentheses = true ij_javascript_space_before_for_semicolon = false ij_javascript_space_before_function_left_parenth = true ij_javascript_space_before_generator_mult = false ij_javascript_space_before_if_left_brace = true ij_javascript_space_before_if_parentheses = true ij_javascript_space_before_method_call_parentheses = false ij_javascript_space_before_method_left_brace = true ij_javascript_space_before_method_parentheses = false ij_javascript_space_before_property_colon = false ij_javascript_space_before_quest = true ij_javascript_space_before_switch_left_brace = true ij_javascript_space_before_switch_parentheses = true ij_javascript_space_before_try_left_brace = true ij_javascript_space_before_type_colon = false ij_javascript_space_before_unary_not = false ij_javascript_space_before_while_keyword = true ij_javascript_space_before_while_left_brace = true ij_javascript_space_before_while_parentheses = true ij_javascript_spaces_around_additive_operators = true ij_javascript_spaces_around_arrow_function_operator = true ij_javascript_spaces_around_assignment_operators = true ij_javascript_spaces_around_bitwise_operators = true ij_javascript_spaces_around_equality_operators = true ij_javascript_spaces_around_logical_operators = true ij_javascript_spaces_around_multiplicative_operators = true ij_javascript_spaces_around_relational_operators = true ij_javascript_spaces_around_shift_operators = true ij_javascript_spaces_around_unary_operator = false ij_javascript_spaces_within_array_initializer_brackets = false ij_javascript_spaces_within_brackets = false ij_javascript_spaces_within_catch_parentheses = false ij_javascript_spaces_within_for_parentheses = false ij_javascript_spaces_within_if_parentheses = false ij_javascript_spaces_within_imports = false ij_javascript_spaces_within_interpolation_expressions = false ij_javascript_spaces_within_method_call_parentheses = false ij_javascript_spaces_within_method_parentheses = false ij_javascript_spaces_within_object_literal_braces = false ij_javascript_spaces_within_object_type_braces = true ij_javascript_spaces_within_parentheses = false ij_javascript_spaces_within_switch_parentheses = false ij_javascript_spaces_within_type_assertion = false ij_javascript_spaces_within_union_types = true ij_javascript_spaces_within_while_parentheses = false ij_javascript_special_else_if_treatment = true ij_javascript_ternary_operation_signs_on_next_line = false ij_javascript_ternary_operation_wrap = off ij_javascript_union_types_wrap = on_every_item ij_javascript_use_chained_calls_group_indents = false ij_javascript_use_double_quotes = true ij_javascript_use_explicit_js_extension = global ij_javascript_use_path_mapping = always ij_javascript_use_public_modifier = false ij_javascript_use_semicolon_after_statement = true ij_javascript_var_declaration_wrap = normal ij_javascript_while_brace_force = never ij_javascript_while_on_new_line = false ij_javascript_wrap_comments = false [{*.ft,*.vm,*.vsl}] ij_vtl_keep_indents_on_empty_lines = false [{*.gant,*.gradle,*.groovy,*.gson,*.gy}] ij_groovy_align_group_field_declarations = false ij_groovy_align_multiline_array_initializer_expression = false ij_groovy_align_multiline_assignment = false ij_groovy_align_multiline_binary_operation = false ij_groovy_align_multiline_chained_methods = false ij_groovy_align_multiline_extends_list = false ij_groovy_align_multiline_for = true ij_groovy_align_multiline_list_or_map = true ij_groovy_align_multiline_method_parentheses = false ij_groovy_align_multiline_parameters = true ij_groovy_align_multiline_parameters_in_calls = false ij_groovy_align_multiline_resources = true ij_groovy_align_multiline_ternary_operation = false ij_groovy_align_multiline_throws_list = false ij_groovy_align_named_args_in_map = true ij_groovy_align_throws_keyword = false ij_groovy_array_initializer_new_line_after_left_brace = false ij_groovy_array_initializer_right_brace_on_new_line = false ij_groovy_array_initializer_wrap = off ij_groovy_assert_statement_wrap = off ij_groovy_assignment_wrap = off ij_groovy_binary_operation_wrap = off ij_groovy_blank_lines_after_class_header = 0 ij_groovy_blank_lines_after_imports = 1 ij_groovy_blank_lines_after_package = 1 ij_groovy_blank_lines_around_class = 1 ij_groovy_blank_lines_around_field = 0 ij_groovy_blank_lines_around_field_in_interface = 0 ij_groovy_blank_lines_around_method = 1 ij_groovy_blank_lines_around_method_in_interface = 1 ij_groovy_blank_lines_before_imports = 1 ij_groovy_blank_lines_before_method_body = 0 ij_groovy_blank_lines_before_package = 0 ij_groovy_block_brace_style = end_of_line ij_groovy_block_comment_at_first_column = true ij_groovy_call_parameters_new_line_after_left_paren = false ij_groovy_call_parameters_right_paren_on_new_line = false ij_groovy_call_parameters_wrap = off ij_groovy_catch_on_new_line = false ij_groovy_class_annotation_wrap = split_into_lines ij_groovy_class_brace_style = end_of_line ij_groovy_class_count_to_use_import_on_demand = 5 ij_groovy_do_while_brace_force = never ij_groovy_else_on_new_line = false ij_groovy_enum_constants_wrap = off ij_groovy_extends_keyword_wrap = off ij_groovy_extends_list_wrap = off ij_groovy_field_annotation_wrap = split_into_lines ij_groovy_finally_on_new_line = false ij_groovy_for_brace_force = never ij_groovy_for_statement_new_line_after_left_paren = false ij_groovy_for_statement_right_paren_on_new_line = false ij_groovy_for_statement_wrap = off ij_groovy_if_brace_force = never ij_groovy_import_annotation_wrap = 2 ij_groovy_imports_layout = *,|,javax.**,java.**,|,$* ij_groovy_indent_case_from_switch = true ij_groovy_indent_label_blocks = true ij_groovy_insert_inner_class_imports = false ij_groovy_keep_blank_lines_before_right_brace = 2 ij_groovy_keep_blank_lines_in_code = 2 ij_groovy_keep_blank_lines_in_declarations = 2 ij_groovy_keep_control_statement_in_one_line = true ij_groovy_keep_first_column_comment = true ij_groovy_keep_indents_on_empty_lines = false ij_groovy_keep_line_breaks = true ij_groovy_keep_multiple_expressions_in_one_line = false ij_groovy_keep_simple_blocks_in_one_line = false ij_groovy_keep_simple_classes_in_one_line = true ij_groovy_keep_simple_lambdas_in_one_line = true ij_groovy_keep_simple_methods_in_one_line = true ij_groovy_label_indent_absolute = false ij_groovy_label_indent_size = 0 ij_groovy_lambda_brace_style = end_of_line ij_groovy_layout_static_imports_separately = true ij_groovy_line_comment_add_space = false ij_groovy_line_comment_at_first_column = true ij_groovy_method_annotation_wrap = split_into_lines ij_groovy_method_brace_style = end_of_line ij_groovy_method_call_chain_wrap = off ij_groovy_method_parameters_new_line_after_left_paren = false ij_groovy_method_parameters_right_paren_on_new_line = false ij_groovy_method_parameters_wrap = off ij_groovy_modifier_list_wrap = false ij_groovy_names_count_to_use_import_on_demand = 3 ij_groovy_parameter_annotation_wrap = off ij_groovy_parentheses_expression_new_line_after_left_paren = false ij_groovy_parentheses_expression_right_paren_on_new_line = false ij_groovy_prefer_parameters_wrap = false ij_groovy_resource_list_new_line_after_left_paren = false ij_groovy_resource_list_right_paren_on_new_line = false ij_groovy_resource_list_wrap = off ij_groovy_space_after_assert_separator = true ij_groovy_space_after_colon = true ij_groovy_space_after_comma = true ij_groovy_space_after_comma_in_type_arguments = true ij_groovy_space_after_for_semicolon = true ij_groovy_space_after_quest = true ij_groovy_space_after_type_cast = true ij_groovy_space_before_annotation_parameter_list = false ij_groovy_space_before_array_initializer_left_brace = false ij_groovy_space_before_assert_separator = false ij_groovy_space_before_catch_keyword = true ij_groovy_space_before_catch_left_brace = true ij_groovy_space_before_catch_parentheses = true ij_groovy_space_before_class_left_brace = true ij_groovy_space_before_closure_left_brace = true ij_groovy_space_before_colon = true ij_groovy_space_before_comma = false ij_groovy_space_before_do_left_brace = true ij_groovy_space_before_else_keyword = true ij_groovy_space_before_else_left_brace = true ij_groovy_space_before_finally_keyword = true ij_groovy_space_before_finally_left_brace = true ij_groovy_space_before_for_left_brace = true ij_groovy_space_before_for_parentheses = true ij_groovy_space_before_for_semicolon = false ij_groovy_space_before_if_left_brace = true ij_groovy_space_before_if_parentheses = true ij_groovy_space_before_method_call_parentheses = false ij_groovy_space_before_method_left_brace = true ij_groovy_space_before_method_parentheses = false ij_groovy_space_before_quest = true ij_groovy_space_before_switch_left_brace = true ij_groovy_space_before_switch_parentheses = true ij_groovy_space_before_synchronized_left_brace = true ij_groovy_space_before_synchronized_parentheses = true ij_groovy_space_before_try_left_brace = true ij_groovy_space_before_try_parentheses = true ij_groovy_space_before_while_keyword = true ij_groovy_space_before_while_left_brace = true ij_groovy_space_before_while_parentheses = true ij_groovy_space_in_named_argument = true ij_groovy_space_in_named_argument_before_colon = false ij_groovy_space_within_empty_array_initializer_braces = false ij_groovy_space_within_empty_method_call_parentheses = false ij_groovy_spaces_around_additive_operators = true ij_groovy_spaces_around_assignment_operators = true ij_groovy_spaces_around_bitwise_operators = true ij_groovy_spaces_around_equality_operators = true ij_groovy_spaces_around_lambda_arrow = true ij_groovy_spaces_around_logical_operators = true ij_groovy_spaces_around_multiplicative_operators = true ij_groovy_spaces_around_regex_operators = true ij_groovy_spaces_around_relational_operators = true ij_groovy_spaces_around_shift_operators = true ij_groovy_spaces_within_annotation_parentheses = false ij_groovy_spaces_within_array_initializer_braces = false ij_groovy_spaces_within_braces = true ij_groovy_spaces_within_brackets = false ij_groovy_spaces_within_cast_parentheses = false ij_groovy_spaces_within_catch_parentheses = false ij_groovy_spaces_within_for_parentheses = false ij_groovy_spaces_within_gstring_injection_braces = false ij_groovy_spaces_within_if_parentheses = false ij_groovy_spaces_within_list_or_map = false ij_groovy_spaces_within_method_call_parentheses = false ij_groovy_spaces_within_method_parentheses = false ij_groovy_spaces_within_parentheses = false ij_groovy_spaces_within_switch_parentheses = false ij_groovy_spaces_within_synchronized_parentheses = false ij_groovy_spaces_within_try_parentheses = false ij_groovy_spaces_within_tuple_expression = false ij_groovy_spaces_within_while_parentheses = false ij_groovy_special_else_if_treatment = true ij_groovy_ternary_operation_wrap = off ij_groovy_throws_keyword_wrap = off ij_groovy_throws_list_wrap = off ij_groovy_use_flying_geese_braces = false ij_groovy_use_fq_class_names = false ij_groovy_use_fq_class_names_in_javadoc = true ij_groovy_use_relative_indents = false ij_groovy_use_single_class_imports = true ij_groovy_variable_annotation_wrap = off ij_groovy_while_brace_force = never ij_groovy_while_on_new_line = false ij_groovy_wrap_chain_calls_after_dot = false ij_groovy_wrap_long_lines = false [{*.gradle.kts,*.kt,*.kts,*.main.kts,*.space.kts}] ij_kotlin_align_in_columns_case_branch = false ij_kotlin_align_multiline_binary_operation = false ij_kotlin_align_multiline_extends_list = false ij_kotlin_align_multiline_method_parentheses = false ij_kotlin_align_multiline_parameters = true ij_kotlin_align_multiline_parameters_in_calls = false ij_kotlin_allow_trailing_comma = false ij_kotlin_allow_trailing_comma_on_call_site = false ij_kotlin_assignment_wrap = normal ij_kotlin_blank_lines_after_class_header = 0 ij_kotlin_blank_lines_around_block_when_branches = 0 ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 ij_kotlin_block_comment_at_first_column = true ij_kotlin_call_parameters_new_line_after_left_paren = true ij_kotlin_call_parameters_right_paren_on_new_line = true ij_kotlin_call_parameters_wrap = on_every_item ij_kotlin_catch_on_new_line = false ij_kotlin_class_annotation_wrap = split_into_lines ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL ij_kotlin_continuation_indent_for_chained_calls = false ij_kotlin_continuation_indent_for_expression_bodies = false ij_kotlin_continuation_indent_in_argument_lists = false ij_kotlin_continuation_indent_in_elvis = false ij_kotlin_continuation_indent_in_if_conditions = false ij_kotlin_continuation_indent_in_parameter_lists = false ij_kotlin_continuation_indent_in_supertype_lists = false ij_kotlin_else_on_new_line = false ij_kotlin_enum_constants_wrap = off ij_kotlin_extends_list_wrap = normal ij_kotlin_field_annotation_wrap = split_into_lines ij_kotlin_finally_on_new_line = false ij_kotlin_if_rparen_on_new_line = true ij_kotlin_import_nested_classes = false ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ ij_kotlin_insert_whitespaces_in_simple_one_line_method = true ij_kotlin_keep_blank_lines_before_right_brace = 2 ij_kotlin_keep_blank_lines_in_code = 2 ij_kotlin_keep_blank_lines_in_declarations = 2 ij_kotlin_keep_first_column_comment = true ij_kotlin_keep_indents_on_empty_lines = false ij_kotlin_keep_line_breaks = true ij_kotlin_lbrace_on_next_line = false ij_kotlin_line_comment_add_space = false ij_kotlin_line_comment_at_first_column = true ij_kotlin_method_annotation_wrap = split_into_lines ij_kotlin_method_call_chain_wrap = normal ij_kotlin_method_parameters_new_line_after_left_paren = true ij_kotlin_method_parameters_right_paren_on_new_line = true ij_kotlin_method_parameters_wrap = on_every_item ij_kotlin_name_count_to_use_star_import = 5 ij_kotlin_name_count_to_use_star_import_for_members = 3 ij_kotlin_packages_to_use_import_on_demand = java.util.*,kotlinx.android.synthetic.**,io.ktor.** ij_kotlin_parameter_annotation_wrap = off ij_kotlin_space_after_comma = true ij_kotlin_space_after_extend_colon = true ij_kotlin_space_after_type_colon = true ij_kotlin_space_before_catch_parentheses = true ij_kotlin_space_before_comma = false ij_kotlin_space_before_extend_colon = true ij_kotlin_space_before_for_parentheses = true ij_kotlin_space_before_if_parentheses = true ij_kotlin_space_before_lambda_arrow = true ij_kotlin_space_before_type_colon = false ij_kotlin_space_before_when_parentheses = true ij_kotlin_space_before_while_parentheses = true ij_kotlin_spaces_around_additive_operators = true ij_kotlin_spaces_around_assignment_operators = true ij_kotlin_spaces_around_equality_operators = true ij_kotlin_spaces_around_function_type_arrow = true ij_kotlin_spaces_around_logical_operators = true ij_kotlin_spaces_around_multiplicative_operators = true ij_kotlin_spaces_around_range = false ij_kotlin_spaces_around_relational_operators = true ij_kotlin_spaces_around_unary_operator = false ij_kotlin_spaces_around_when_arrow = true ij_kotlin_variable_annotation_wrap = off ij_kotlin_while_on_new_line = false ij_kotlin_wrap_elvis_expressions = 1 ij_kotlin_wrap_expression_body_functions = 1 ij_kotlin_wrap_first_method_in_call_chain = false [{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}] indent_size = 2 ij_json_keep_blank_lines_in_code = 0 ij_json_keep_indents_on_empty_lines = false ij_json_keep_line_breaks = true ij_json_space_after_colon = true ij_json_space_after_comma = true ij_json_space_before_colon = true ij_json_space_before_comma = false ij_json_spaces_within_braces = false ij_json_spaces_within_brackets = false ij_json_wrap_long_lines = false [{*.htm,*.html,*.ng,*.sht,*.shtm,*.shtml}] ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3 ij_html_align_attributes = true ij_html_align_text = false ij_html_attribute_wrap = normal ij_html_block_comment_at_first_column = true ij_html_do_not_align_children_of_min_lines = 0 ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot ij_html_enforce_quotes = false ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var ij_html_keep_blank_lines = 2 ij_html_keep_indents_on_empty_lines = false ij_html_keep_line_breaks = true ij_html_keep_line_breaks_in_text = true ij_html_keep_whitespaces = false ij_html_keep_whitespaces_inside = span,pre,textarea ij_html_line_comment_at_first_column = true ij_html_new_line_after_last_attribute = never ij_html_new_line_before_first_attribute = never ij_html_quote_style = double ij_html_remove_new_line_before_tags = br ij_html_space_after_tag_name = false ij_html_space_around_equality_in_attribute = false ij_html_space_inside_empty_tag = false ij_html_text_wrap = normal [{*.jsf,*.jsp,*.jspf,*.tag,*.tagf,*.xjsp}] ij_jsp_jsp_prefer_comma_separated_import_list = false ij_jsp_keep_indents_on_empty_lines = false [{*.jspx,*.tagx}] ij_jspx_keep_indents_on_empty_lines = false [{*.markdown,*.md}] max_line_length = 200 ij_markdown_force_one_space_after_blockquote_symbol = true ij_markdown_force_one_space_after_header_symbol = true ij_markdown_force_one_space_after_list_bullet = true ij_markdown_force_one_space_between_words = true ij_markdown_keep_indents_on_empty_lines = false ij_markdown_max_lines_around_block_elements = 1 ij_markdown_max_lines_around_header = 1 ij_markdown_max_lines_between_paragraphs = 1 ij_markdown_min_lines_around_block_elements = 1 ij_markdown_min_lines_around_header = 1 ij_markdown_min_lines_between_paragraphs = 1 [{*.pb,*.textproto}] indent_size = 2 tab_width = 2 ij_continuation_indent_size = 4 ij_prototext_keep_blank_lines_in_code = 2 ij_prototext_keep_indents_on_empty_lines = false ij_prototext_keep_line_breaks = true ij_prototext_space_after_colon = true ij_prototext_space_after_comma = true ij_prototext_space_before_colon = false ij_prototext_space_before_comma = false ij_prototext_spaces_within_braces = true ij_prototext_spaces_within_brackets = false [{*.properties,spring.handlers,spring.schemas}] ij_properties_align_group_field_declarations = false ij_properties_keep_blank_lines = false ij_properties_key_value_delimiter = equals ij_properties_spaces_around_key_value_delimiter = false [{*.yaml,*.yml}] indent_size = 2 ij_yaml_align_values_properties = do_not_align ij_yaml_autoinsert_sequence_marker = true ij_yaml_block_mapping_on_new_line = false ij_yaml_indent_sequence_value = true ij_yaml_keep_indents_on_empty_lines = false ij_yaml_keep_line_breaks = true ij_yaml_sequence_on_new_line = false ij_yaml_space_before_colon = false ij_yaml_spaces_within_braces = true ij_yaml_spaces_within_brackets = true ================================================ FILE: .gitee/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: QQ 群 url: https://docs.hmcl.net/groups.html about: Hello Minecraft! Launcher 的官方 QQ 交流群。 ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: Bug 反馈 | Bug Report description: 反馈一个 HMCL 错误。| File a bug report for HMCL. title: "[Bug] " labels: bug body: - type: markdown attributes: value: | 提交前请确认: * 该问题确实是 **HMCL 的错误**,而**不是 Minecraft 非正常退出**。如果你的 Minecraft 非正常退出,请前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。 * 你的启动器版本是**最新的预览版本**。你可以从 [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) 或 [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main) 下载最新预览版本。 如果你的问题并不属于上述两类,你可以选取另一种 Issue 类型,或者直接前往 [QQ 群](https://docs.hmcl.net/groups.html)/[Discord 服务器](https://discord.gg/jVvC7HfM6U) 获取帮助。 Before submitting, please confirm: * The issue is indeed **a bug of HMCL**, not **Minecraft abnormal exit**. If your Minecraft exits abnormally, please go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help. * Your launcher is the **latest nightly build**. You can download the latest nightly build from [GitHub Actions](https://github.com/HMCL-dev/HMCL/actions/workflows/gradle.yml?query=branch%3Amain+event%3Apush) or [nightly.link](https://nightly.link/HMCL-dev/HMCL/workflows/gradle/main). If your issue does not fall into the above two categories, you can choose another type of issue or directly go to the [QQ group](https://docs.hmcl.net/groups.html) or [Discord server](https://discord.gg/jVvC7HfM6U) for help. - type: textarea id: bug-report attributes: label: 问题描述 | Bug Description description: | 请尽可能地详细描述你所遇到的问题,并描述如何重新触发这个问题。 Please describe the bug you met in as much detail as possible. Additionally, describe the steps to reproduce this bug. placeholder: | 1. 点击 HMCL 上的某个按钮 | Click a button named ... 2. 向下翻页 | Scroll down 3. ... validations: required: true - type: textarea id: hmcl-crash-report-or-logs attributes: label: 启动器崩溃报告 / 启动器日志文件 | Launcher Crash Report / Launcher Log File description: | 如果你的启动器崩溃了,请将崩溃报告填入 (或将文件拖入) 下方。 如果你的启动器没有崩溃,请在遇到问题后**不要退出启动器**,在启动器的 “设置 → 通用 → 调试” 一栏中点击 “导出启动器日志”,并将导出的日志拖到下方的输入栏中。 **请注意:启动器崩溃报告或日志文件是诊断问题的重要依据,请务必上传!** If your launcher crashes, please fill in (or drag the file in) the following input field with the crash report. If your launcher does not crash, please DO NOT EXIT your launcher, click "Export Launcher Logs" in the "Settings → General → Debug" of the launcher, and drag the exported log to the following input field. **ATTENTION: The crash report or log file is the key to resolving the bug. Please upload them!** validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: QQ 群 | QQ Group url: https://docs.hmcl.net/groups.html about: Hello Minecraft! Launcher 的官方 QQ 交流群。| The official QQ group of Hello Minecraft! Launcher. - name: Discord 服务器 | Discord Server url: https://discord.gg/jVvC7HfM6U about: Hello Minecraft! Launcher 的官方 Discord 服务器。| The official Discord server of Hello Minecraft! Launcher. - name: 其他反馈 | Others url: https://github.com/HMCL-dev/HMCL/discussions/new/choose about: 通过 Discussions 反馈其他问题。| Report other problems in Discussions. ================================================ FILE: .github/ISSUE_TEMPLATE/feature.yml ================================================ name: 新功能 | Feature Request description: 为 HMCL 提出新功能。| Suggest a new feature or enhancement for HMCL. title: "[Feature] " labels: enhancement body: - type: markdown attributes: value: | 请确认 Issues 列表无重复的项目。 Please make sure that no duplicate issues have already been submitted. - type: textarea id: summary attributes: label: 概述 | Summary description: | 请介绍你想加入的新功能。 Please describe the new feature. validations: required: true - type: textarea id: reason attributes: label: 原因 | Reason description: | 请描述该功能带来的好处及原因。 Please describe why you want to add the feature or enhancement to HMCL. validations: required: true - type: textarea id: description attributes: label: 详情 | Description description: | 在这里可以补充描述该功能的具体实现方式或建议。(可选) Describe implementation details or suggestions here. (Optional) ================================================ FILE: .github/workflows/check-codes.yml ================================================ name: Check Codes on: push: paths: - '**.java' - '**.properties' pull_request: paths: - '**.java' - '**.properties' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up JDK 17 uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: '17' java-package: 'jdk+fx' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: cache-cleanup: never - name: Check Codes run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace ================================================ FILE: .github/workflows/gradle.yml ================================================ name: Java CI on: push: pull_request: paths-ignore: - '**.md' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up JDK 17 uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: '17' java-package: 'jdk+fx' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: cache-cleanup: never - name: Build with Gradle run: ./gradlew build --no-daemon --parallel env: MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }} CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }} - name: Get short SHA run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV - name: Upload JAR uses: actions/upload-artifact@v7 with: name: HMCL-${{ env.SHORT_SHA }}-jar path: HMCL/build/libs/HMCL-*.jar archive: false - name: Upload EXE uses: actions/upload-artifact@v7 with: name: HMCL-${{ env.SHORT_SHA }}-exe path: HMCL/build/libs/HMCL-*.exe archive: false - name: Upload SH uses: actions/upload-artifact@v7 with: name: HMCL-${{ env.SHORT_SHA }}-sh path: HMCL/build/libs/HMCL-*.sh archive: false ================================================ FILE: .github/workflows/mirror.yml ================================================ name: Mirror Repository on: workflow_dispatch: push: concurrency: group: mirror-repository cancel-in-progress: true jobs: mirror: strategy: fail-fast: false matrix: include: - name: Gitee repo: gitee.com/huanghongxun/HMCL user: 'hmcl-sync' token: 'GITEE_SYNC_TOKEN' - name: CNB repo: cnb.cool/HMCL-dev/HMCL user: 'cnb' token: 'CNB_SYNC_TOKEN' name: Mirror to ${{ matrix.name }} if: ${{ github.repository == 'HMCL-dev/HMCL' }} runs-on: ubuntu-latest steps: - name: Mirror GitHub to ${{ matrix.name }} run: | git clone --mirror "https://github.com/${{ github.repository }}.git" -- repo cd repo git push -f --prune "https://${{ matrix.user }}:${{ secrets[matrix.token] }}@${{ matrix.repo }}.git" "refs/heads/*:refs/heads/*" "refs/tags/*:refs/tags/*" ================================================ FILE: .github/workflows/release.yml ================================================ name: Create Release on: workflow_dispatch: # schedule: # - cron: '30 * * * *' permissions: contents: write jobs: check-update: if: ${{ github.repository_owner == 'HMCL-dev' }} strategy: fail-fast: false max-parallel: 1 matrix: include: - channel: dev task: checkUpdateDev - channel: stable task: checkUpdateStable runs-on: ubuntu-latest name: check-update-${{ matrix.channel }} steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 fetch-tags: true - name: Set up JDK uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '25' - name: Fetch last version run: ./gradlew ${{ matrix.task }} --no-daemon --info --stacktrace - name: Check for existing tags run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi - name: Download artifacts if: ${{ env.continue == 'true' }} run: | wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe" wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256" wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar" wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256" wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh" wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256" - name: Verify artifacts if: ${{ env.continue == 'true' }} run: | export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n') export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n') export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n') echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c - name: Generate release note if: ${{ env.continue == 'true' }} run: | # GitHub Release Note echo " **[Changelog](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)** (Chinese)" >> RELEASE_NOTE echo "" >> RELEASE_NOTE echo "| File | SHA-256 Checksum |" >> RELEASE_NOTE echo "| --- | --- |" >> RELEASE_NOTE echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE # CNB Release Note echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE echo "" >> CNB_RELEASE_NOTE echo "| 文件 | SHA-256 校验码 |" >> CNB_RELEASE_NOTE echo "| :--- | --- |" >> CNB_RELEASE_NOTE echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE env: GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download - name: Create GitHub release if: ${{ env.continue == 'true' }} run: | gh release create "${{ env.HMCL_TAG_NAME }}" \ "HMCL-${{ env.HMCL_VERSION }}.exe" \ "HMCL-${{ env.HMCL_VERSION }}.jar" \ "HMCL-${{ env.HMCL_VERSION }}.sh" \ --target "${{ env.HMCL_COMMIT_SHA }}" \ --title "${{ env.HMCL_TAG_NAME }}" \ --notes-file RELEASE_NOTE \ --prerelease env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install git-cnb if: ${{ env.continue == 'true' }} run: go install "cnb.cool/looc/git-cnb@$GIT_CNB_VERSION" env: GIT_CNB_VERSION: '1.1.2' - name: Create CNB release if: ${{ env.continue == 'true' }} run: | echo "Uploading tags to CNB" git fetch --tags git push "https://cnb:${{ secrets.CNB_SYNC_TOKEN }}@cnb.cool/$CNB_REPO.git" "$HMCL_TAG_NAME" echo "Creating CNB release" ~/go/bin/git-cnb release create \ --repo "$CNB_REPO" \ --tag "$HMCL_TAG_NAME" \ --name "HMCL $HMCL_VERSION" \ --body "$(cat CNB_RELEASE_NOTE)" \ --prerelease true echo "Uploading HMCL-$HMCL_VERSION.jar" ~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.jar" echo "Uploading HMCL-$HMCL_VERSION.exe" ~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.exe" echo "Uploading HMCL-$HMCL_VERSION.sh" ~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh" env: CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }} CNB_REPO: HMCL-dev/HMCL ================================================ FILE: .gitignore ================================================ *.class # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* *.hprof .gradle *.lck *.1 *.2 *.log .mine* /externalgames NVIDIA minecraft-exported-crash-info* hmcl-exported-logs-* terracotta-log-* /.java/ /.local/ /.cache/ # IANA Language Subtag Registry language-subtag-registry # gradle build /build/ /HMCL/build/ /HMCLCore/build/ /HMCLBoot/build/ /minecraft/libraries/HMCLTransformerDiscoveryService/build/ /minecraft/libraries/HMCLMultiMCBootstrap/build/ /buildSrc/build/ # idea .idea /out/ /HMCL/out/ /HMCLCore/out/ /minecraft/libraries/HMCLTransformerDiscoveryService/out/ /minecraft/libraries/HMCLMultiMCBootstrap/out/ # eclipse /bin/ /HMCL/bin/ /HMCLCore/bin/ /minecraft/libraries/HMCLTransformerDiscoveryService/bin/ /minecraft/libraries/HMCLMultiMCBootstrap/bin/ .classpath .project .settings # netbeans .nb-gradle *.exe # macos .DS_Store # vscode .vscode/ # test /hmcl.json /.hmcl.json /.hmcl/ ================================================ FILE: HMCL/.gitignore ================================================ /data.csv /data.json /mod.json /modpack.json ================================================ FILE: HMCL/build.gradle.kts ================================================ import org.jackhuang.hmcl.gradle.TerracottaConfigUpgradeTask import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils import org.jackhuang.hmcl.gradle.ci.JenkinsUtils import org.jackhuang.hmcl.gradle.l10n.CheckTranslations import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate import org.jackhuang.hmcl.gradle.mod.ParseModDataTask import org.jackhuang.hmcl.gradle.utils.PropertiesUtils import java.net.URI import java.nio.file.FileSystems import java.nio.file.Files import java.security.KeyFactory import java.security.MessageDigest import java.security.Signature import java.security.spec.PKCS8EncodedKeySpec import java.util.zip.ZipFile plugins { alias(libs.plugins.shadow) } val projectConfig = PropertiesUtils.load(rootProject.file("config/project.properties").toPath()) val isOfficial = JenkinsUtils.IS_ON_CI || GitHubActionUtils.IS_ON_OFFICIAL_REPO val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" else "unofficial" val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3" val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: "" val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: "" val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: "" val buildNumber = System.getenv("BUILD_NUMBER")?.toInt() if (buildNumber != null) { version = if (JenkinsUtils.IS_ON_CI && versionType == "dev") { "$versionRoot.0.$buildNumber" } else { "$versionRoot.$buildNumber" } } else { val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7) version = if (shortCommit.isNullOrBlank()) { "$versionRoot.SNAPSHOT" } else if (isOfficial) { "$versionRoot.dev-$shortCommit" } else { "$versionRoot.unofficial-$shortCommit" } } val embedResources by configurations.registering dependencies { implementation(project(":HMCLCore")) implementation(project(":HMCLBoot")) implementation("libs:JFoenix") implementation(libs.twelvemonkeys.imageio.webp) implementation(libs.java.info) implementation(libs.monet.fx) implementation(libs.nayuki.qrcodegen) if (launcherExe.isBlank()) { implementation(libs.hmclauncher) } embedResources(libs.authlib.injector) } fun digest(algorithm: String, bytes: ByteArray): ByteArray = MessageDigest.getInstance(algorithm).digest(bytes) fun createChecksum(file: File) { val algorithms = linkedMapOf( "SHA-1" to "sha1", "SHA-256" to "sha256", "SHA-512" to "sha512" ) algorithms.forEach { (algorithm, ext) -> File(file.parentFile, "${file.name}.$ext").writeText( digest(algorithm, file.readBytes()).joinToString(separator = "", postfix = "\n") { "%02x".format(it) } ) } } fun attachSignature(jar: File) { val keyLocation = System.getenv("HMCL_SIGNATURE_KEY") if (keyLocation == null) { logger.warn("Missing signature key") return } val privatekey = KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(File(keyLocation).readBytes())) val signer = Signature.getInstance("SHA512withRSA") signer.initSign(privatekey) ZipFile(jar).use { zip -> zip.stream() .sorted(Comparator.comparing { it.name }) .filter { it.name != "META-INF/hmcl_signature" } .forEach { signer.update(digest("SHA-512", it.name.toByteArray())) signer.update(digest("SHA-512", zip.getInputStream(it).readBytes())) } } val signature = signer.sign() FileSystems.newFileSystem(URI.create("jar:" + jar.toURI()), emptyMap()).use { zipfs -> Files.newOutputStream(zipfs.getPath("META-INF/hmcl_signature")).use { it.write(signature) } } } tasks.withType { sourceCompatibility = "17" targetCompatibility = "17" } tasks.checkstyleMain { // Third-party code is not checked exclude("**/org/jackhuang/hmcl/ui/image/apng/**") } val addOpens = listOf( "java.base/java.lang", "java.base/java.lang.reflect", "java.base/jdk.internal.loader", "javafx.base/com.sun.javafx.binding", "javafx.base/com.sun.javafx.event", "javafx.base/com.sun.javafx.runtime", "javafx.base/javafx.beans.property", "javafx.graphics/javafx.css", "javafx.graphics/javafx.stage", "javafx.graphics/javafx.scene", "javafx.graphics/com.sun.glass.ui", "javafx.graphics/com.sun.javafx.stage", "javafx.graphics/com.sun.javafx.util", "javafx.graphics/com.sun.prism", "javafx.controls/com.sun.javafx.scene.control", "javafx.controls/com.sun.javafx.scene.control.behavior", "javafx.graphics/com.sun.javafx.tk.quantum", "javafx.controls/javafx.scene.control.skin", "jdk.attach/sun.tools.attach", ) tasks.compileJava { options.compilerArgs.addAll(addOpens.map { "--add-exports=$it=ALL-UNNAMED" }) } val hmclProperties = buildList { add("hmcl.version" to project.version.toString()) add("hmcl.add-opens" to addOpens.joinToString(" ")) System.getenv("GITHUB_SHA")?.let { add("hmcl.version.hash" to it) } add("hmcl.version.type" to versionType) add("hmcl.microsoft.auth.id" to microsoftAuthId) add("hmcl.curseforge.apikey" to curseForgeApiKey) add("hmcl.authlib-injector.version" to libs.authlib.injector.get().version!!) } val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties") val createPropertiesFile by tasks.registering { outputs.file(hmclPropertiesFile) hmclProperties.forEach { (k, v) -> inputs.property(k, v) } doLast { val targetFile = hmclPropertiesFile.get().asFile targetFile.parentFile.mkdir() targetFile.bufferedWriter().use { for ((k, v) in hmclProperties) { it.write("$k=$v\n") } } } } tasks.jar { enabled = false dependsOn(tasks["shadowJar"]) } val jarPath = tasks.jar.get().archiveFile.get().asFile tasks.shadowJar { dependsOn(createPropertiesFile) archiveClassifier.set(null as String?) exclude("**/package-info.class") exclude("META-INF/maven/**") exclude("META-INF/services/javax.imageio.spi.ImageReaderSpi") exclude("META-INF/services/javax.imageio.spi.ImageInputStreamSpi") listOf( "aix-*", "sunos-*", "openbsd-*", "dragonflybsd-*", "freebsd-*", "linux-*", "darwin-*", "*-ppc", "*-ppc64le", "*-s390x", "*-armel", ).forEach { exclude("com/sun/jna/$it/**") } minimize { exclude(dependency("com.google.code.gson:.*:.*")) exclude(dependency("net.java.dev.jna:jna:.*")) exclude(dependency("libs:JFoenix:.*")) exclude(project(":HMCLBoot")) } manifest.attributes( "Created-By" to "Copyright(c) 2013-2025 huangyuhui.", "Implementation-Version" to project.version.toString(), "Main-Class" to "org.jackhuang.hmcl.Main", "Multi-Release" to "true", "Add-Opens" to addOpens.joinToString(" "), "Enable-Native-Access" to "ALL-UNNAMED", "Enable-Final-Field-Mutation" to "ALL-UNNAMED", ) if (launcherExe.isNotBlank()) { into("assets") { from(file(launcherExe)) } } doLast { attachSignature(jarPath) createChecksum(jarPath) } } tasks.processResources { dependsOn(createPropertiesFile) dependsOn(upsideDownTranslate) dependsOn(createLocaleNamesResourceBundle) dependsOn(createLanguageList) into("assets/") { from(hmclPropertiesFile) from(embedResources) } into("assets/lang") { from(createLanguageList.map { it.outputFile }) from(upsideDownTranslate.map { it.outputFile }) from(createLocaleNamesResourceBundle.map { it.outputDirectory }) } inputs.property("terracotta_version", libs.versions.terracotta) doLast { upgradeTerracottaConfig.get().checkValid() } } val makeExecutables by tasks.registering { val extensions = listOf("exe", "sh") dependsOn(tasks.jar) inputs.file(jarPath) outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) }) doLast { val jarContent = jarPath.readBytes() ZipFile(jarPath).use { zipFile -> for (extension in extensions) { val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension) val entry = zipFile.getEntry("assets/HMCLauncher.$extension") ?: throw GradleException("HMCLauncher.$extension not found") output.outputStream().use { outputStream -> zipFile.getInputStream(entry).use { it.copyTo(outputStream) } outputStream.write(jarContent) } createChecksum(output) } } } } tasks.build { dependsOn(makeExecutables) } fun parseToolOptions(options: String?): MutableList { if (options == null) return mutableListOf() val builder = StringBuilder() val result = mutableListOf() var offset = 0 loop@ while (offset < options.length) { val ch = options[offset] if (Character.isWhitespace(ch)) { if (builder.isNotEmpty()) { result += builder.toString() builder.clear() } while (offset < options.length && Character.isWhitespace(options[offset])) { offset++ } continue@loop } if (ch == '\'' || ch == '"') { offset++ while (offset < options.length) { val ch2 = options[offset++] if (ch2 != ch) { builder.append(ch2) } else { continue@loop } } throw GradleException("Unmatched quote in $options") } builder.append(ch) offset++ } if (builder.isNotEmpty()) { result += builder.toString() } return result } // For IntelliJ IDEA tasks.withType { if (name != "run") { jvmArgs(addOpens.map { "--add-opens=$it=ALL-UNNAMED" }) // if (javaVersion >= JavaVersion.VERSION_24) { // jvmArgs("--enable-native-access=ALL-UNNAMED") // } } } tasks.register("run") { dependsOn(tasks.jar) group = "application" classpath = files(jarPath) workingDir = rootProject.rootDir val vmOptions = parseToolOptions(System.getenv("HMCL_JAVA_OPTS") ?: "-Xmx1g") if (vmOptions.none { it.startsWith("-Dhmcl.offline.auth.restricted=") }) vmOptions += "-Dhmcl.offline.auth.restricted=false" jvmArgs(vmOptions) val hmclJavaHome = System.getenv("HMCL_JAVA_HOME") if (hmclJavaHome != null) { this.executable( file(hmclJavaHome).resolve("bin") .resolve(if (System.getProperty("os.name").lowercase().startsWith("windows")) "java.exe" else "java") ) } doFirst { logger.quiet("HMCL_JAVA_OPTS: {}", vmOptions) logger.quiet("HMCL_JAVA_HOME: {}", hmclJavaHome ?: System.getProperty("java.home")) } } // terracotta val upgradeTerracottaConfig = tasks.register("upgradeTerracottaConfig") { val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json") val source = layout.projectDirectory.file("terracotta-template.json"); classifiers.set(listOf( "windows-x86_64", "windows-arm64", "macos-x86_64", "macos-arm64", "linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64", "freebsd-x86_64" )) version.set(libs.versions.terracotta) downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz") templateFile.set(source) outputFile.set(destination) } // Check Translations tasks.register("checkTranslations") { val dir = layout.projectDirectory.dir("src/main/resources/assets/lang") englishFile.set(dir.file("I18N.properties")) simplifiedChineseFile.set(dir.file("I18N_zh_CN.properties")) traditionalChineseFile.set(dir.file("I18N_zh.properties")) classicalChineseFile.set(dir.file("I18N_lzh.properties")) } // l10n val generatedDir = layout.buildDirectory.dir("generated") val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) { inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties")) outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") }) } val createLanguageList by tasks.registering(CreateLanguageList::class) { resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang")) resourceBundleBaseName.set("I18N") additionalLanguages.set(listOf("en-Qabs")) outputFile.set(generatedDir.map { it.file("languages.json") }) } val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) { dependsOn(createLanguageList) languagesFile.set(createLanguageList.flatMap { it.outputFile }) outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") }) } // mcmod data tasks.register("parseModData") { inputFile.set(layout.projectDirectory.file("mod.json")) outputFile.set(layout.projectDirectory.file("src/main/resources/assets/mod_data.txt")) } tasks.register("parseModPackData") { inputFile.set(layout.projectDirectory.file("modpack.json")) outputFile.set(layout.projectDirectory.file("src/main/resources/assets/modpack_data.txt")) } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXButton.java ================================================ // // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package com.jfoenix.controls; import com.jfoenix.converters.ButtonTypeConverter; import com.jfoenix.skins.JFXButtonSkin; import java.util.ArrayList; import java.util.Collections; import java.util.List; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.css.CssMetaData; import javafx.css.SimpleStyleableObjectProperty; import javafx.css.Styleable; import javafx.css.StyleableObjectProperty; import javafx.css.StyleableProperty; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.Control; import javafx.scene.control.Labeled; import javafx.scene.control.Skin; import javafx.scene.paint.Paint; public class JFXButton extends Button { private static final String DEFAULT_STYLE_CLASS = "jfx-button"; private List> STYLEABLES; public JFXButton() { this.initialize(); } public JFXButton(String text) { super(text); this.initialize(); } public JFXButton(String text, Node graphic) { super(text, graphic); this.initialize(); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } protected Skin createDefaultSkin() { return new JFXButtonSkin(this); } private final ObjectProperty ripplerFill = new SimpleObjectProperty<>(this, "ripplerFill", null); public final ObjectProperty ripplerFillProperty() { return this.ripplerFill; } public final Paint getRipplerFill() { return this.ripplerFillProperty().get(); } public final void setRipplerFill(Paint ripplerFill) { this.ripplerFillProperty().set(ripplerFill); } private final StyleableObjectProperty buttonType = new SimpleStyleableObjectProperty<>( JFXButton.StyleableProperties.BUTTON_TYPE, this, "buttonType", JFXButton.ButtonType.FLAT); public ButtonType getButtonType() { return this.buttonType == null ? JFXButton.ButtonType.FLAT : this.buttonType.get(); } public StyleableObjectProperty buttonTypeProperty() { return this.buttonType; } public void setButtonType(ButtonType type) { this.buttonType.set(type); } public List> getControlCssMetaData() { if (this.STYLEABLES == null) { List> styleables = new ArrayList<>(Control.getClassCssMetaData()); styleables.addAll(getClassCssMetaData()); styleables.addAll(Labeled.getClassCssMetaData()); this.STYLEABLES = List.copyOf(styleables); } return this.STYLEABLES; } public static List> getClassCssMetaData() { return JFXButton.StyleableProperties.CHILD_STYLEABLES; } protected void layoutChildren() { super.layoutChildren(); this.setNeedsLayout(false); } public enum ButtonType { FLAT, RAISED; } private static final class StyleableProperties { private static final CssMetaData BUTTON_TYPE; private static final List> CHILD_STYLEABLES; static { BUTTON_TYPE = new CssMetaData<>("-jfx-button-type", ButtonTypeConverter.getInstance(), JFXButton.ButtonType.FLAT) { public boolean isSettable(JFXButton control) { return control.buttonType == null || !control.buttonType.isBound(); } public StyleableProperty getStyleableProperty(JFXButton control) { return control.buttonTypeProperty(); } }; List> styleables = new ArrayList<>(Control.getClassCssMetaData()); Collections.addAll(styleables, BUTTON_TYPE); CHILD_STYLEABLES = List.copyOf(styleables); } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXCheckBox.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXCheckBoxSkin; import javafx.css.*; import javafx.css.converter.PaintConverter; import javafx.scene.control.CheckBox; import javafx.scene.control.Skin; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import java.util.ArrayList; import java.util.Collections; import java.util.List; /// JFXCheckBox is the material design implementation of a checkbox. /// it shows ripple effect and a custom selection animation. /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXCheckBox extends CheckBox { public JFXCheckBox(String text) { super(text); initialize(); } public JFXCheckBox() { initialize(); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } @Override protected Skin createDefaultSkin() { return new JFXCheckBoxSkin(this); } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ /// Initialize the style class to 'jfx-check-box'. /// /// This is the selector class from which CSS can be used to style /// this control. private static final String DEFAULT_STYLE_CLASS = "jfx-check-box"; /// checkbox color property when selected private StyleableObjectProperty checkedColor; private static final Color DEFAULT_CHECKED_COLOR = Color.valueOf("#0F9D58"); public StyleableObjectProperty checkedColorProperty() { if (checkedColor == null) { checkedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.CHECKED_COLOR, JFXCheckBox.this, "checkedColor", DEFAULT_CHECKED_COLOR); } return this.checkedColor; } public Paint getCheckedColor() { return checkedColor == null ? DEFAULT_CHECKED_COLOR : checkedColor.get(); } public void setCheckedColor(Paint color) { this.checkedColor.set(color); } /** * checkbox color property when not selected */ private StyleableObjectProperty unCheckedColor; private static final Color DEFAULT_UNCHECKED_COLOR = Color.valueOf("#5A5A5A"); public StyleableObjectProperty unCheckedColorProperty() { if (unCheckedColor == null) { unCheckedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNCHECKED_COLOR, JFXCheckBox.this, "unCheckedColor", DEFAULT_UNCHECKED_COLOR); } return this.unCheckedColor; } public Paint getUnCheckedColor() { return unCheckedColor == null ? DEFAULT_UNCHECKED_COLOR : unCheckedColor.get(); } public void setUnCheckedColor(Paint color) { this.unCheckedColor.set(color); } private static final class StyleableProperties { private static final CssMetaData CHECKED_COLOR = new CssMetaData<>("-jfx-checked-color", PaintConverter.getInstance(), DEFAULT_CHECKED_COLOR) { @Override public boolean isSettable(JFXCheckBox control) { return control.checkedColor == null || !control.checkedColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXCheckBox control) { return control.checkedColorProperty(); } }; private static final CssMetaData UNCHECKED_COLOR = new CssMetaData<>("-jfx-unchecked-color", PaintConverter.getInstance(), DEFAULT_UNCHECKED_COLOR) { @Override public boolean isSettable(JFXCheckBox control) { return control.unCheckedColor == null || !control.unCheckedColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXCheckBox control) { return control.unCheckedColorProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(CheckBox.getClassCssMetaData()); Collections.addAll(styleables, CHECKED_COLOR, UNCHECKED_COLOR ); CHILD_STYLEABLES = List.copyOf(styleables); } } @Override public List> getControlCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXClippedPane.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.utils.JFXNodeUtils; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.layout.*; import javafx.scene.paint.Color; /** * JFXClippedPane is a StackPane that clips its content if exceeding the pane bounds. * * @author Shadi Shaheen * @version 1.0 * @since 2018-06-02 */ public class JFXClippedPane extends StackPane { private final Region clip = new Region(); public JFXClippedPane() { super(); init(); } public JFXClippedPane(Node... children) { super(children); init(); } private void init() { setClip(clip); clip.setBackground(new Background(new BackgroundFill(Color.BLACK, new CornerRadii(2), Insets.EMPTY))); backgroundProperty().addListener(observable -> JFXNodeUtils.updateBackground(getBackground(), clip)); } @Override protected void layoutChildren() { super.layoutChildren(); clip.resizeRelocate(0, 0, getWidth(), getHeight()); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXColorPicker.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXColorPickerSkin; import javafx.css.CssMetaData; import javafx.css.SimpleStyleableBooleanProperty; import javafx.css.Styleable; import javafx.css.StyleableBooleanProperty; import javafx.css.converter.BooleanConverter; import javafx.scene.control.ColorPicker; import javafx.scene.control.Skin; import javafx.scene.paint.Color; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * JFXColorPicker is the metrial design implementation of color picker. * * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ public class JFXColorPicker extends ColorPicker { /** * {@inheritDoc} */ public JFXColorPicker() { initialize(); } /** * {@inheritDoc} */ public JFXColorPicker(Color color) { super(color); initialize(); } /** * {@inheritDoc} */ @Override protected Skin createDefaultSkin() { return new JFXColorPickerSkin(this); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } /** * Initialize the style class to 'jfx-color-picker'. *

* This is the selector class from which CSS can be used to style * this control. */ private static final String DEFAULT_STYLE_CLASS = "jfx-color-picker"; private double[] preDefinedColors = null; public double[] getPreDefinedColors() { return preDefinedColors; } public void setPreDefinedColors(double[] preDefinedColors) { this.preDefinedColors = preDefinedColors; } /** * disable animation on button action */ private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXColorPicker.this, "disableAnimation", false); public final StyleableBooleanProperty disableAnimationProperty() { return this.disableAnimation; } public final Boolean isDisableAnimation() { return disableAnimation != null && this.disableAnimationProperty().get(); } public final void setDisableAnimation(final Boolean disabled) { this.disableAnimationProperty().set(disabled); } private static final class StyleableProperties { private static final CssMetaData DISABLE_ANIMATION = new CssMetaData("-jfx-disable-animation", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXColorPicker control) { return control.disableAnimation == null || !control.disableAnimation.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXColorPicker control) { return control.disableAnimationProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(ColorPicker.getClassCssMetaData()); Collections.addAll(styleables, DISABLE_ANIMATION); CHILD_STYLEABLES = Collections.unmodifiableList(styleables); } } @Override public List> getControlCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXComboBox.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.converters.base.NodeConverter; import com.jfoenix.skins.JFXComboBoxListViewSkin; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.ObservableList; import javafx.css.*; import javafx.css.converter.BooleanConverter; import javafx.css.converter.PaintConverter; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.util.StringConverter; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu; /** * JFXComboBox is the material design implementation of a combobox. * * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ public class JFXComboBox extends ComboBox { /** * {@inheritDoc} */ public JFXComboBox() { initialize(); } /** * {@inheritDoc} */ public JFXComboBox(ObservableList items) { super(items); initialize(); } private void initialize() { getStyleClass().add(DEFAULT_STYLE_CLASS); this.setCellFactory(listView -> new JFXListCell() { @Override public void updateItem(T item, boolean empty) { super.updateItem(item, empty); updateDisplayText(this, item, empty); } }); // had to refactor the code out of the skin class to allow // customization of the button cell this.setButtonCell(new ListCell() { { // fixed clearing the combo box value is causing // java prompt text to be shown because the button cell is not updated JFXComboBox.this.valueProperty().addListener(observable -> { if (JFXComboBox.this.getValue() == null) { updateItem(null, true); } }); } @Override protected void updateItem(T item, boolean empty) { updateDisplayText(this, item, empty); this.setVisible(item != null || !empty); } }); useJFXContextMenu(editorProperty().get()); } /** * {@inheritDoc} */ @Override protected Skin createDefaultSkin() { return new JFXComboBoxListViewSkin(this); } /** * Initialize the style class to 'jfx-combo-box'. *

* This is the selector class from which CSS can be used to style * this control. */ private static final String DEFAULT_STYLE_CLASS = "jfx-combo-box"; /*************************************************************************** * * * Node Converter Property * * * **************************************************************************/ /** * Converts the user-typed input (when the ComboBox is * {@link #editableProperty() editable}) to an object of type T, such that * the input may be retrieved via the {@link #valueProperty() value} property. */ public ObjectProperty> nodeConverterProperty() { return nodeConverter; } private ObjectProperty> nodeConverter = new SimpleObjectProperty<>(this, "nodeConverter", JFXComboBox.defaultNodeConverter()); public final void setNodeConverter(NodeConverter value) { nodeConverterProperty().set(value); } public final NodeConverter getNodeConverter() { return nodeConverterProperty().get(); } private static NodeConverter defaultNodeConverter() { return new NodeConverter() { @Override public Node toNode(T object) { if (object == null) { return null; } StackPane selectedValueContainer = new StackPane(); selectedValueContainer.getStyleClass().add("combo-box-selected-value-container"); selectedValueContainer.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, null, null))); Label selectedValueLabel = object instanceof Label ? new Label(((Label) object).getText()) : new Label( object.toString()); selectedValueLabel.setTextFill(Color.BLACK); selectedValueContainer.getChildren().add(selectedValueLabel); StackPane.setAlignment(selectedValueLabel, Pos.CENTER_LEFT); StackPane.setMargin(selectedValueLabel, new Insets(0, 0, 0, 5)); return selectedValueContainer; } @SuppressWarnings("unchecked") @Override public T fromNode(Node node) { return (T) node; } @Override public String toString(T object) { if (object == null) { return null; } if (object instanceof Label) { return ((Label) object).getText(); } return object.toString(); } }; } private boolean updateDisplayText(ListCell cell, T item, boolean empty) { if (empty) { // create empty cell if (cell == null) { return true; } cell.setGraphic(null); cell.setText(null); return true; } else if (item instanceof Node) { Node currentNode = cell.getGraphic(); Node newNode = (Node) item; // create a node from the selected node of the listview // using JFXComboBox {@link #nodeConverterProperty() NodeConverter}) NodeConverter nc = this.getNodeConverter(); Node node = nc == null ? null : nc.toNode(item); if (currentNode == null || !currentNode.equals(newNode)) { cell.setText(null); cell.setGraphic(node == null ? newNode : node); } return node == null; } else { // run item through StringConverter if it isn't null StringConverter c = this.getConverter(); String s = item == null ? this.getPromptText() : (c == null ? item.toString() : c.toString(item)); cell.setText(s); cell.setGraphic(null); return s == null || s.isEmpty(); } } /*************************************************************************** * * * styleable Properties * * * **************************************************************************/ /** * set true to show a float the prompt text when focusing the field */ private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXComboBox.this, "lableFloat", false); public final StyleableBooleanProperty labelFloatProperty() { return this.labelFloat; } public final boolean isLabelFloat() { return this.labelFloatProperty().get(); } public final void setLabelFloat(final boolean labelFloat) { this.labelFloatProperty().set(labelFloat); } /** * default color used when the field is unfocused */ private StyleableObjectProperty unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXComboBox.this, "unFocusColor", Color.rgb(77, 77, 77)); public Paint getUnFocusColor() { return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get(); } public StyleableObjectProperty unFocusColorProperty() { return this.unFocusColor; } public void setUnFocusColor(Paint color) { this.unFocusColor.set(color); } /** * default color used when the field is focused */ private StyleableObjectProperty focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXComboBox.this, "focusColor", Color.valueOf("#4059A9")); public Paint getFocusColor() { return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get(); } public StyleableObjectProperty focusColorProperty() { return this.focusColor; } public void setFocusColor(Paint color) { this.focusColor.set(color); } private final static class StyleableProperties { private static final CssMetaData, Paint> UNFOCUS_COLOR = new CssMetaData, Paint>( "-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) { @Override public boolean isSettable(JFXComboBox control) { return control.unFocusColor == null || !control.unFocusColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXComboBox control) { return control.unFocusColorProperty(); } }; private static final CssMetaData, Paint> FOCUS_COLOR = new CssMetaData, Paint>( "-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) { @Override public boolean isSettable(JFXComboBox control) { return control.focusColor == null || !control.focusColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXComboBox control) { return control.focusColorProperty(); } }; private static final CssMetaData, Boolean> LABEL_FLOAT = new CssMetaData, Boolean>( "-jfx-label-float", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXComboBox control) { return control.labelFloat == null || !control.labelFloat.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXComboBox control) { return control.labelFloatProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>( Control.getClassCssMetaData()); Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT); CHILD_STYLEABLES = Collections.unmodifiableList(styleables); } } // inherit the styleable properties from parent private List> STYLEABLES; @Override public List> getControlCssMetaData() { if (STYLEABLES == null) { final List> styleables = new ArrayList<>( Control.getClassCssMetaData()); styleables.addAll(getClassCssMetaData()); styleables.addAll(Control.getClassCssMetaData()); STYLEABLES = Collections.unmodifiableList(styleables); } return STYLEABLES; } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXDialog.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.controls.events.JFXDialogEvent; import com.jfoenix.converters.DialogTransitionConverter; import com.jfoenix.effects.JFXDepthManager; import com.jfoenix.transitions.CachedTransition; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.animation.Transition; import javafx.beans.DefaultProperty; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectPropertyBase; import javafx.beans.property.SimpleBooleanProperty; import javafx.css.CssMetaData; import javafx.css.SimpleStyleableObjectProperty; import javafx.css.Styleable; import javafx.css.StyleableObjectProperty; import javafx.css.StyleableProperty; import javafx.event.Event; import javafx.event.EventHandler; import javafx.geometry.Pos; import javafx.scene.CacheHint; import javafx.scene.Node; import javafx.scene.SnapshotParameters; import javafx.scene.image.ImageView; import javafx.scene.image.WritableImage; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.util.Duration; import org.jackhuang.hmcl.ui.animation.Motion; import java.util.ArrayList; import java.util.Collections; import java.util.List; /// Note: for JFXDialog to work properly, the root node **MUST** /// be of type [StackPane] /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 @DefaultProperty(value = "content") public class JFXDialog extends StackPane { private static final double INITIAL_SCALE = 0.8; // public static enum JFXDialogLayout{PLAIN, HEADING, ACTIONS, BACKDROP}; public enum DialogTransition { CENTER, NONE } private StackPane contentHolder; private double offsetX = 0; private double offsetY = 0; private StackPane dialogContainer; private Region content; private Transition animation; private final EventHandler closeHandler = e -> close(); /// creates empty JFXDialog control with CENTER animation type public JFXDialog() { this(null, null, DialogTransition.CENTER); } /// creates empty JFXDialog control with a specified animation type public JFXDialog(DialogTransition transition) { this(null, null, transition); } /// creates JFXDialog control with a specified animation type, the animation type /// can be one of the following: /// /// - CENTER /// - TOP /// - RIGHT /// - BOTTOM /// - LEFT /// /// @param dialogContainer is the parent of the dialog, it /// @param content the content of dialog /// @param transitionType the animation type public JFXDialog(StackPane dialogContainer, Region content, DialogTransition transitionType) { initialize(); setContent(content); setDialogContainer(dialogContainer); this.transitionType.set(transitionType); // init change listeners initChangeListeners(); } /// creates JFXDialog control with a specified animation type that /// is closed when clicking on the overlay, the animation type /// can be one of the following: /// /// - CENTER /// - TOP /// - RIGHT /// - BOTTOM /// - LEFT public JFXDialog(StackPane dialogContainer, Region content, DialogTransition transitionType, boolean overlayClose) { setOverlayClose(overlayClose); initialize(); setContent(content); setDialogContainer(dialogContainer); this.transitionType.set(transitionType); // init change listeners initChangeListeners(); } private void initChangeListeners() { overlayCloseProperty().addListener((o, oldVal, newVal) -> { if (newVal) { this.addEventHandler(MouseEvent.MOUSE_PRESSED, closeHandler); } else { this.removeEventHandler(MouseEvent.MOUSE_PRESSED, closeHandler); } }); } private void initialize() { this.setVisible(false); this.getStyleClass().add(DEFAULT_STYLE_CLASS); this.transitionType.addListener((o, oldVal, newVal) -> { animation = getShowAnimation(transitionType.get()); }); contentHolder = new StackPane(); contentHolder.setBackground(new Background(new BackgroundFill(Color.WHITE, new CornerRadii(2), null))); JFXDepthManager.setDepth(contentHolder, 4); contentHolder.setPickOnBounds(false); // ensure stackpane is never resized beyond it's preferred size contentHolder.setMaxSize(Region.USE_PREF_SIZE, Region.USE_PREF_SIZE); this.getChildren().add(contentHolder); this.getStyleClass().add("jfx-dialog-overlay-pane"); StackPane.setAlignment(contentHolder, Pos.CENTER); this.setBackground(new Background(new BackgroundFill(Color.rgb(0, 0, 0, 0.1), null, null))); // close the dialog if clicked on the overlay pane if (overlayClose.get()) { this.addEventHandler(MouseEvent.MOUSE_PRESSED, closeHandler); } // prevent propagating the events to overlay pane contentHolder.addEventHandler(MouseEvent.ANY, Event::consume); } /*************************************************************************** * * * Setters / Getters * * * **************************************************************************/ /// @return the dialog container public StackPane getDialogContainer() { return dialogContainer; } /// set the dialog container /// Note: the dialog container must be StackPane, its the container for the dialog to be shown in. public void setDialogContainer(StackPane dialogContainer) { if (dialogContainer != null) { this.dialogContainer = dialogContainer; // FIXME: need to be improved to consider only the parent boundary offsetX = dialogContainer.getBoundsInLocal().getWidth(); offsetY = dialogContainer.getBoundsInLocal().getHeight(); animation = getShowAnimation(transitionType.get()); } } /// @return dialog content node public Region getContent() { return content; } /// set the content of the dialog public void setContent(Region content) { if (content != null) { this.content = content; this.content.setPickOnBounds(false); contentHolder.getChildren().setAll(content); } } /// indicates whether the dialog will close when clicking on the overlay or not private final BooleanProperty overlayClose = new SimpleBooleanProperty(true); public final BooleanProperty overlayCloseProperty() { return this.overlayClose; } public final boolean isOverlayClose() { return this.overlayCloseProperty().get(); } public final void setOverlayClose(final boolean overlayClose) { this.overlayCloseProperty().set(overlayClose); } /// if sets to true, the content of dialog container will be cached and replaced with an image /// when displaying the dialog (better performance). /// this is recommended if the content behind the dialog will not change during the showing /// period private final BooleanProperty cacheContainer = new SimpleBooleanProperty(false); public boolean isCacheContainer() { return cacheContainer.get(); } public BooleanProperty cacheContainerProperty() { return cacheContainer; } public void setCacheContainer(boolean cacheContainer) { this.cacheContainer.set(cacheContainer); } /// it will show the dialog in the specified container public void show(StackPane dialogContainer) { this.setDialogContainer(dialogContainer); showDialog(); } private ArrayList tempContent; /** * show the dialog inside its parent container */ public void show() { this.setDialogContainer(dialogContainer); showDialog(); } private void showDialog() { if (dialogContainer == null) { throw new RuntimeException("ERROR: JFXDialog container is not set!"); } if (isCacheContainer()) { tempContent = new ArrayList<>(dialogContainer.getChildren()); SnapshotParameters snapShotparams = new SnapshotParameters(); snapShotparams.setFill(Color.TRANSPARENT); WritableImage temp = dialogContainer.snapshot(snapShotparams, new WritableImage((int) dialogContainer.getWidth(), (int) dialogContainer.getHeight())); ImageView tempImage = new ImageView(temp); tempImage.setCache(true); tempImage.setCacheHint(CacheHint.SPEED); dialogContainer.getChildren().setAll(tempImage, this); } else { //prevent error if opening an already opened dialog dialogContainer.getChildren().remove(this); tempContent = null; dialogContainer.getChildren().add(this); } if (animation != null) { animation.play(); } else { setVisible(true); setOpacity(1); Event.fireEvent(JFXDialog.this, new JFXDialogEvent(JFXDialogEvent.OPENED)); } } /** * close the dialog */ public void close() { if (animation != null) { animation.setRate(-2); animation.play(); animation.setOnFinished(e -> { closeDialog(); }); } else { setOpacity(0); setVisible(false); closeDialog(); } } private void closeDialog() { resetProperties(); Event.fireEvent(JFXDialog.this, new JFXDialogEvent(JFXDialogEvent.CLOSED)); if (tempContent == null) { dialogContainer.getChildren().remove(this); } else { dialogContainer.getChildren().setAll(tempContent); } } /*************************************************************************** * * * Transitions * * * **************************************************************************/ private Transition getShowAnimation(DialogTransition transitionType) { Transition animation = null; if (contentHolder != null) { animation = switch (transitionType) { case CENTER -> { contentHolder.setScaleX(INITIAL_SCALE); contentHolder.setScaleY(INITIAL_SCALE); yield new CenterTransition(); } case NONE -> { contentHolder.setScaleX(1); contentHolder.setScaleY(1); contentHolder.setTranslateX(0); contentHolder.setTranslateY(0); yield null; } }; } if (animation != null) { animation.setOnFinished(finish -> Event.fireEvent(JFXDialog.this, new JFXDialogEvent(JFXDialogEvent.OPENED))); } return animation; } private void resetProperties() { this.setVisible(false); contentHolder.setTranslateX(0); contentHolder.setTranslateY(0); contentHolder.setScaleX(1); contentHolder.setScaleY(1); } private final class CenterTransition extends CachedTransition { private static final Interpolator INTERPOLATOR = Motion.EMPHASIZED_DECELERATE; CenterTransition() { super(contentHolder, new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(contentHolder.scaleXProperty(), INITIAL_SCALE, INTERPOLATOR), new KeyValue(contentHolder.scaleYProperty(), INITIAL_SCALE, INTERPOLATOR), new KeyValue(JFXDialog.this.visibleProperty(), false, Motion.LINEAR) ), new KeyFrame(Duration.millis(10), new KeyValue(JFXDialog.this.visibleProperty(), true, Motion.LINEAR), new KeyValue(JFXDialog.this.opacityProperty(), 0, INTERPOLATOR) ), new KeyFrame(Motion.EXTRA_LONG4, new KeyValue(contentHolder.scaleXProperty(), 1, INTERPOLATOR), new KeyValue(contentHolder.scaleYProperty(), 1, INTERPOLATOR), new KeyValue(JFXDialog.this.visibleProperty(), true, Motion.LINEAR), new KeyValue(JFXDialog.this.opacityProperty(), 1, INTERPOLATOR) )) ); // reduce the number to increase the shifting , increase number to reduce shifting setCycleDuration(Duration.seconds(0.4)); setDelay(Duration.ZERO); } } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ /// Initialize the style class to 'jfx-dialog'. /// /// This is the selector class from which CSS can be used to style /// this control. private static final String DEFAULT_STYLE_CLASS = "jfx-dialog"; /// dialog transition type property, it can be one of the following: /// /// - CENTER /// - TOP /// - RIGHT /// - BOTTOM /// - LEFT /// - NONE private final StyleableObjectProperty transitionType = new SimpleStyleableObjectProperty<>( StyleableProperties.DIALOG_TRANSITION, JFXDialog.this, "dialogTransition", DialogTransition.CENTER); public DialogTransition getTransitionType() { return transitionType == null ? DialogTransition.CENTER : transitionType.get(); } public StyleableObjectProperty transitionTypeProperty() { return this.transitionType; } public void setTransitionType(DialogTransition transition) { this.transitionType.set(transition); } private static final class StyleableProperties { private static final CssMetaData DIALOG_TRANSITION = new CssMetaData("-jfx-dialog-transition", DialogTransitionConverter.getInstance(), DialogTransition.CENTER) { @Override public boolean isSettable(JFXDialog control) { return control.transitionType == null || !control.transitionType.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXDialog control) { return control.transitionTypeProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(StackPane.getClassCssMetaData()); Collections.addAll(styleables, DIALOG_TRANSITION ); CHILD_STYLEABLES = Collections.unmodifiableList(styleables); } } @Override public List> getCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } /*************************************************************************** * * * Custom Events * * * **************************************************************************/ private final ObjectProperty> onDialogClosedProperty = new ObjectPropertyBase>() { @Override protected void invalidated() { setEventHandler(JFXDialogEvent.CLOSED, get()); } @Override public Object getBean() { return JFXDialog.this; } @Override public String getName() { return "onClosed"; } }; /** * Defines a function to be called when the dialog is closed. * Note: it will be triggered after the close animation is finished. */ public ObjectProperty> onDialogClosedProperty() { return onDialogClosedProperty; } public void setOnDialogClosed(EventHandler handler) { onDialogClosedProperty().set(handler); } public EventHandler getOnDialogClosed() { return onDialogClosedProperty().get(); } private final ObjectProperty> onDialogOpenedProperty = new ObjectPropertyBase<>() { @Override protected void invalidated() { setEventHandler(JFXDialogEvent.OPENED, get()); } @Override public Object getBean() { return JFXDialog.this; } @Override public String getName() { return "onOpened"; } }; /** * Defines a function to be called when the dialog is opened. * Note: it will be triggered after the show animation is finished. */ public ObjectProperty> onDialogOpenedProperty() { return onDialogOpenedProperty; } public void setOnDialogOpened(EventHandler handler) { onDialogOpenedProperty().set(handler); } public EventHandler getOnDialogOpened() { return onDialogOpenedProperty().get(); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXDialogLayout.java ================================================ // // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package com.jfoenix.controls; import java.util.List; import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Orientation; import javafx.scene.Node; import javafx.scene.layout.FlowPane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; public class JFXDialogLayout extends StackPane { private final StackPane heading = new StackPane(); private final StackPane body = new StackPane(); private final FlowPane actions = new FlowPane() { protected double computeMinWidth(double height) { if (this.getContentBias() == Orientation.HORIZONTAL) { double maxPref = 0.0; for (Node child : this.getChildren()) { if (child.isManaged()) { maxPref = Math.max(maxPref, child.minWidth(-1.0)); } } Insets insets = this.getInsets(); return insets.getLeft() + this.snapSizeX(maxPref) + insets.getRight(); } else { return this.computePrefWidth(height); } } protected double computeMinHeight(double width) { if (this.getContentBias() == Orientation.VERTICAL) { double maxPref = 0.0; for (Node child : this.getChildren()) { if (child.isManaged()) { maxPref = Math.max(maxPref, child.minHeight(-1.0)); } } Insets insets = this.getInsets(); return insets.getTop() + this.snapSizeY(maxPref) + insets.getBottom(); } else { return this.computePrefHeight(width); } } }; private static final String DEFAULT_STYLE_CLASS = "jfx-dialog-layout"; public JFXDialogLayout() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); VBox layout = new VBox(); this.heading.getStyleClass().add("jfx-layout-heading"); this.heading.getStyleClass().add("title"); this.body.getStyleClass().add("jfx-layout-body"); this.body.prefHeightProperty().bind(this.prefHeightProperty()); this.body.prefWidthProperty().bind(this.prefWidthProperty()); this.actions.getStyleClass().add("jfx-layout-actions"); layout.getChildren().setAll(this.heading, this.body, this.actions); this.getChildren().add(layout); } public ObservableList getHeading() { return this.heading.getChildren(); } public void setHeading(Node... titleContent) { this.heading.getChildren().setAll(titleContent); } public ObservableList getBody() { return this.body.getChildren(); } public void setBody(Node... body) { this.body.getChildren().setAll(body); } public ObservableList getActions() { return this.actions.getChildren(); } public void setActions(Node... actions) { this.actions.getChildren().setAll(actions); } public void setActions(List actions) { this.actions.getChildren().setAll(actions); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXListCell.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.utils.JFXNodeUtils; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.Tooltip; import javafx.scene.layout.Region; import javafx.scene.shape.Rectangle; import javafx.scene.shape.Shape; import javafx.util.Duration; import java.util.Set; /// material design implementation of ListCell /// /// By default, JFXListCell will try to create a graphic node for the cell, /// to override it you need to set graphic to null in [#updateItem(Object, boolean)] method. /// /// NOTE: passive nodes (Labels and Shapes) will be set to mouse transparent in order to /// show the ripple effect upon clicking , to change this behavior you can override the /// method {[#makeChildrenTransparent()] /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXListCell extends ListCell { protected JFXRippler cellRippler = new JFXRippler(this) { @Override protected Node getMask() { Region clip = new Region(); JFXNodeUtils.updateBackground(JFXListCell.this.getBackground(), clip); double width = control.getLayoutBounds().getWidth(); double height = control.getLayoutBounds().getHeight(); clip.resize(width, height); return clip; } @Override protected void positionControl(Node control) { // do nothing } }; protected Node cellContent; private Rectangle clip; private Timeline gapAnimation; private boolean playExpandAnimation = false; private boolean selectionChanged = false; /** * {@inheritDoc} */ public JFXListCell() { initialize(); initListeners(); } /** * init listeners to update the vertical gap / selection animation */ private void initListeners() { listViewProperty().addListener((listObj, oldList, newList) -> { if (newList instanceof JFXListView listView) { listView.currentVerticalGapProperty().addListener((o, oldVal, newVal) -> { cellRippler.rippler.setClip(null); if (newVal.doubleValue() != 0) { playExpandAnimation = true; getListView().requestLayout(); } else { // fake expand state double gap = clip.getY() * 2; gapAnimation = new Timeline( new KeyFrame(Duration.millis(240), new KeyValue(this.translateYProperty(), -gap / 2 - (gap * (getIndex())), Interpolator.EASE_BOTH) )); gapAnimation.play(); gapAnimation.setOnFinished((finish) -> { requestLayout(); Platform.runLater(() -> getListView().requestLayout()); }); } }); selectedProperty().addListener((o, oldVal, newVal) -> { if (newVal) { selectionChanged = true; } }); } }); } @Override protected void layoutChildren() { super.layoutChildren(); cellRippler.resizeRelocate(0, 0, getWidth(), getHeight()); double gap = getGap(); if (clip == null) { clip = new Rectangle(0, gap / 2, getWidth(), getHeight() - gap); setClip(clip); } else { if (gap != 0) { if (playExpandAnimation || selectionChanged) { // fake list collapse state if (playExpandAnimation) { this.setTranslateY(-gap / 2 + (-gap * (getIndex()))); clip.setY(gap / 2); clip.setHeight(getHeight() - gap); gapAnimation = new Timeline(new KeyFrame(Duration.millis(240), new KeyValue(this.translateYProperty(), 0, Interpolator.EASE_BOTH))); playExpandAnimation = false; } else if (selectionChanged) { clip.setY(0); clip.setHeight(getHeight()); gapAnimation = new Timeline( new KeyFrame(Duration.millis(240), new KeyValue(clip.yProperty(), gap / 2, Interpolator.EASE_BOTH), new KeyValue(clip.heightProperty(), getHeight() - gap, Interpolator.EASE_BOTH) )); } playExpandAnimation = false; selectionChanged = false; gapAnimation.play(); } else { if (gapAnimation != null) { gapAnimation.stop(); } this.setTranslateY(0); clip.setY(gap / 2); clip.setHeight(getHeight() - gap); } } else { this.setTranslateY(0); clip.setY(0); clip.setHeight(getHeight()); } clip.setX(0); clip.setWidth(getWidth()); } if (!getChildren().contains(cellRippler)) { makeChildrenTransparent(); getChildren().add(0, cellRippler); cellRippler.rippler.clear(); } } /** * this method is used to set some nodes in cell content as mouse transparent nodes * so clicking on them will trigger the ripple effect. */ protected void makeChildrenTransparent() { for (Node child : getChildren()) { if (child instanceof Label) { Set texts = child.lookupAll("Text"); for (Node text : texts) { text.setMouseTransparent(true); } } else if (child instanceof Shape) { child.setMouseTransparent(true); } } } /** * {@inheritDoc} */ @Override protected void updateItem(T item, boolean empty) { super.updateItem(item, empty); if (empty) { setText(null); setGraphic(null); // remove empty (Trailing cells) setMouseTransparent(true); setStyle("-fx-background-color:TRANSPARENT;"); } else { setMouseTransparent(false); setStyle(null); if (item instanceof Node newNode) { setText(null); Node currentNode = getGraphic(); if (currentNode == null || !currentNode.equals(newNode)) { cellContent = newNode; cellRippler.rippler.cacheRippleClip(false); // build the Cell node // RIPPLER ITEM : in case if the list item has its own rippler bind the list rippler and item rippler properties if (newNode instanceof JFXRippler newRippler) { // build cell container from exisiting rippler cellRippler.ripplerFillProperty().bind(newRippler.ripplerFillProperty()); cellRippler.maskTypeProperty().bind(newRippler.maskTypeProperty()); cellRippler.positionProperty().bind(newRippler.positionProperty()); cellContent = newRippler.getControl(); } ((Region) cellContent).setMaxHeight(cellContent.prefHeight(-1)); setGraphic(cellContent); } } else { setText(item == null ? "null" : item.toString()); setGraphic(null); } // show cell tooltip if it's toggled in JFXListView if (getListView() instanceof JFXListView listView && listView.isShowTooltip()) { if (item instanceof Label label) { setTooltip(new Tooltip(label.getText())); } else if (getText() != null) { setTooltip(new Tooltip(getText())); } } } } // Stylesheet Handling * /** * Initialize the style class to 'jfx-list-cell'. *

* This is the selector class from which CSS can be used to style * this control. */ private static final String DEFAULT_STYLE_CLASS = "jfx-list-cell"; private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); this.setPadding(new Insets(8, 12, 8, 12)); } @Override protected double computePrefHeight(double width) { double gap = getGap(); return super.computePrefHeight(width) + gap; } private double getGap() { return (getListView() instanceof JFXListView listView) ? (listView.isExpanded() ? listView.currentVerticalGapProperty().get() : 0) : 0; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXListView.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXListViewSkin; import javafx.beans.property.*; import javafx.css.*; import javafx.css.converter.BooleanConverter; import javafx.css.converter.SizeConverter; import javafx.scene.control.ListView; import javafx.scene.control.Skin; import javafx.scene.input.MouseEvent; import java.util.*; /// Material design implementation of List View /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXListView extends ListView { /** * {@inheritDoc} */ public JFXListView() { this.setCellFactory(listView -> new JFXListCell<>()); initialize(); } /** * {@inheritDoc} */ @Override protected Skin createDefaultSkin() { return new JFXListViewSkin<>(this); } private IntegerProperty depth; public IntegerProperty depthProperty() { if (depth == null) { depth = new SimpleIntegerProperty(this, "depth", 0); } return depth; } public int getDepth() { return depth != null ? depth.get() : 0; } public void setDepth(int depth) { depthProperty().set(depth); } private DoubleProperty currentVerticalGap; DoubleProperty currentVerticalGapProperty() { if (currentVerticalGap == null) { currentVerticalGap = new SimpleDoubleProperty(this, "currentVerticalGap"); } return currentVerticalGap; } private void updateVerticalGap() { if (isExpanded()) { currentVerticalGapProperty().set(verticalGap.get()); } else { currentVerticalGapProperty().set(0); } } /* * this only works if the items were labels / strings */ private BooleanProperty showTooltip; public final BooleanProperty showTooltipProperty() { if (showTooltip == null) { showTooltip = new SimpleBooleanProperty(this, "showTooltip", false); } return this.showTooltip; } public final boolean isShowTooltip() { return showTooltip != null && showTooltip.get(); } public final void setShowTooltip(final boolean showTooltip) { this.showTooltipProperty().set(showTooltip); } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ /// Initialize the style class to 'jfx-list-view'. /// /// This is the selector class from which CSS can be used to style /// this control. private static final String DEFAULT_STYLE_CLASS = "jfx-list-view"; private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } /** * propagate mouse events to the parent node ( e.g. to allow dragging while clicking on the list) */ public void propagateMouseEventsToParent() { this.addEventHandler(MouseEvent.ANY, e -> { e.consume(); this.getParent().fireEvent(e); }); } private StyleableDoubleProperty verticalGap; public StyleableDoubleProperty verticalGapProperty() { if (this.verticalGap == null) { this.verticalGap = new StyleableDoubleProperty(0.0) { @Override public Object getBean() { return JFXListView.this; } @Override public String getName() { return "verticalGap"; } @Override public CssMetaData getCssMetaData() { return StyleableProperties.VERTICAL_GAP; } @Override protected void invalidated() { updateVerticalGap(); } }; } return this.verticalGap; } public Double getVerticalGap() { return verticalGap == null ? 0.0 : verticalGap.get(); } public void setVerticalGap(Double gap) { verticalGapProperty().set(gap); } private StyleableBooleanProperty expanded; public StyleableBooleanProperty expandedProperty() { if (expanded == null) { expanded = new StyleableBooleanProperty(false) { @Override public Object getBean() { return JFXListView.this; } @Override public String getName() { return "expanded"; } @Override public CssMetaData getCssMetaData() { return StyleableProperties.EXPANDED; } @Override protected void invalidated() { updateVerticalGap(); } }; } return this.expanded; } public Boolean isExpanded() { return expanded != null && expanded.get(); } public void setExpanded(Boolean expanded) { expandedProperty().set(expanded); } private static final class StyleableProperties { private static final CssMetaData, Number> VERTICAL_GAP = new CssMetaData<>("-jfx-vertical-gap", SizeConverter.getInstance(), 0) { @Override public boolean isSettable(JFXListView control) { return control.verticalGap == null || !control.verticalGap.isBound(); } @Override public StyleableDoubleProperty getStyleableProperty(JFXListView control) { return control.verticalGapProperty(); } }; private static final CssMetaData, Boolean> EXPANDED = new CssMetaData<>("-jfx-expanded", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXListView control) { // it's only settable if the List is not shown yet return control.getHeight() == 0 && (control.expanded == null || !control.expanded.isBound()); } @Override public StyleableBooleanProperty getStyleableProperty(JFXListView control) { return control.expandedProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(ListView.getClassCssMetaData()); Collections.addAll(styleables, VERTICAL_GAP, EXPANDED); CHILD_STYLEABLES = List.copyOf(styleables); } } @Override public List> getControlCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXPasswordField.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXPasswordFieldSkin; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.*; import javafx.css.converter.BooleanConverter; import javafx.css.converter.PaintConverter; import javafx.scene.control.Control; import javafx.scene.control.PasswordField; import javafx.scene.control.Skin; import javafx.scene.control.TextField; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu; /** * JFXPasswordField is the material design implementation of a password Field. * * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ public class JFXPasswordField extends PasswordField { /** * {@inheritDoc} */ public JFXPasswordField() { initialize(); } /** * {@inheritDoc} */ @Override protected Skin createDefaultSkin() { return new JFXPasswordFieldSkin(this); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); if ("dalvik".equals(System.getProperty("java.vm.name").toLowerCase(Locale.ROOT))) { this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXPasswordFieldSkinAndroid\";"); } useJFXContextMenu(this); } /** * Initialize the style class to 'jfx-password-field'. *

* This is the selector class from which CSS can be used to style * this control. */ private static final String DEFAULT_STYLE_CLASS = "jfx-password-field"; /*************************************************************************** * * * Properties * * * **************************************************************************/ /** * holds the current active validator on the password field in case of validation error */ private ReadOnlyObjectWrapper activeValidator = new ReadOnlyObjectWrapper<>(); public ValidatorBase getActiveValidator() { return activeValidator == null ? null : activeValidator.get(); } public ReadOnlyObjectProperty activeValidatorProperty() { return this.activeValidator.getReadOnlyProperty(); } /** * list of validators that will validate the password value upon calling * {{@link #validate()} */ private ObservableList validators = FXCollections.observableArrayList(); public ObservableList getValidators() { return validators; } public void setValidators(ValidatorBase... validators) { this.validators.addAll(validators); } /** * validates the password value using the list of validators provided by the user * {{@link #setValidators(ValidatorBase...)} * * @return true if the value is valid else false */ public boolean validate() { for (ValidatorBase validator : validators) { if (validator.getSrcControl() == null) { validator.setSrcControl(this); } validator.validate(); if (validator.getHasErrors()) { activeValidator.set(validator); return false; } } activeValidator.set(null); return true; } public void resetValidation() { pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false); activeValidator.set(null); } /*************************************************************************** * * * styleable Properties * * * **************************************************************************/ /** * set true to show a float the prompt text when focusing the field */ private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXPasswordField.this, "lableFloat", false); public final StyleableBooleanProperty labelFloatProperty() { return this.labelFloat; } public final boolean isLabelFloat() { return this.labelFloatProperty().get(); } public final void setLabelFloat(final boolean labelFloat) { this.labelFloatProperty().set(labelFloat); } /** * default color used when the field is unfocused */ private StyleableObjectProperty unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXPasswordField.this, "unFocusColor", Color.rgb(77, 77, 77)); public Paint getUnFocusColor() { return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get(); } public StyleableObjectProperty unFocusColorProperty() { return this.unFocusColor; } public void setUnFocusColor(Paint color) { this.unFocusColor.set(color); } /** * default color used when the field is focused */ private StyleableObjectProperty focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXPasswordField.this, "focusColor", Color.valueOf("#4059A9")); public Paint getFocusColor() { return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get(); } public StyleableObjectProperty focusColorProperty() { return this.focusColor; } public void setFocusColor(Paint color) { this.focusColor.set(color); } /** * disable animation on validation */ private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXPasswordField.this, "disableAnimation", false); public final StyleableBooleanProperty disableAnimationProperty() { return this.disableAnimation; } public final Boolean isDisableAnimation() { return disableAnimation != null && this.disableAnimationProperty().get(); } public final void setDisableAnimation(final Boolean disabled) { this.disableAnimationProperty().set(disabled); } private final static class StyleableProperties { private static final CssMetaData UNFOCUS_COLOR = new CssMetaData("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) { @Override public boolean isSettable(JFXPasswordField control) { return control.unFocusColor == null || !control.unFocusColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXPasswordField control) { return control.unFocusColorProperty(); } }; private static final CssMetaData FOCUS_COLOR = new CssMetaData("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) { @Override public boolean isSettable(JFXPasswordField control) { return control.focusColor == null || !control.focusColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXPasswordField control) { return control.focusColorProperty(); } }; private static final CssMetaData LABEL_FLOAT = new CssMetaData("-jfx-label-float", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXPasswordField control) { return control.labelFloat == null || !control.labelFloat.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) { return control.labelFloatProperty(); } }; private static final CssMetaData DISABLE_ANIMATION = new CssMetaData("-fx-disable-animation", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXPasswordField control) { return control.disableAnimation == null || !control.disableAnimation.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXPasswordField control) { return control.disableAnimationProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION); CHILD_STYLEABLES = Collections.unmodifiableList(styleables); } } // inherit the styleable properties from parent private List> STYLEABLES; @Override public List> getControlCssMetaData() { if (STYLEABLES == null) { final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); styleables.addAll(getClassCssMetaData()); styleables.addAll(TextField.getClassCssMetaData()); STYLEABLES = Collections.unmodifiableList(styleables); } return STYLEABLES; } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXPopup.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXPopupSkin; import javafx.application.Platform; import javafx.beans.DefaultProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.geometry.NodeOrientation; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.PopupControl; import javafx.scene.control.Skin; import javafx.scene.layout.Pane; import javafx.scene.layout.Region; import javafx.stage.Window; /// JFXPopup is the material design implementation of a popup. /// /// @author Shadi Shaheen /// @version 2.0 /// @since 2017-03-01 @DefaultProperty(value = "popupContent") public class JFXPopup extends PopupControl { public enum PopupHPosition { RIGHT, LEFT; public PopupHPosition getOpposite() { return (this == RIGHT) ? LEFT : RIGHT; } } public enum PopupVPosition { TOP, BOTTOM } /// Creates empty popup. public JFXPopup() { this(null); } /// creates popup with a specified container and content /// /// @param content the node that will be shown in the popup public JFXPopup(Region content) { setPopupContent(content); initialize(); } private void initialize() { this.setAutoFix(false); this.setAutoHide(true); this.setHideOnEscape(true); this.setConsumeAutoHidingEvents(false); this.getStyleClass().add(DEFAULT_STYLE_CLASS); } @Override protected Skin createDefaultSkin() { return new JFXPopupSkin(this); } /*************************************************************************** * * * Setters / Getters * * * **************************************************************************/ private final ObjectProperty popupContent = new SimpleObjectProperty<>(new Pane()); public final ObjectProperty popupContentProperty() { return this.popupContent; } public final Region getPopupContent() { return this.popupContentProperty().get(); } public final void setPopupContent(final Region popupContent) { this.popupContentProperty().set(popupContent); } /*************************************************************************** * * * Public API * * * **************************************************************************/ /// show the popup using the default position public void show(Node node) { this.show(node, PopupVPosition.TOP, PopupHPosition.LEFT, 0, 0); } /// show the popup according to the specified position /// /// @param vAlign can be TOP/BOTTOM /// @param hAlign can be LEFT/RIGHT public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign) { this.show(node, vAlign, hAlign, 0, 0); } /// show the popup according to the specified position with a certain offset /// /// @param vAlign can be TOP/BOTTOM /// @param hAlign can be LEFT/RIGHT /// @param initOffsetX on the x-axis /// @param initOffsetY on the y-axis public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) { show(node, vAlign, hAlign, initOffsetX, initOffsetY, false); } public void show(Node node, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY, boolean attachToNode) { if (!isShowing()) { Scene scene = node.getScene(); if (scene == null || scene.getWindow() == null) { throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window."); } Window parent = scene.getWindow(); final Point2D origin = node.localToScene(0, 0); boolean isRTL = node.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT; double anchorX = parent.getX() + scene.getX() + origin.getX() + (hAlign == PopupHPosition.RIGHT ? ((Region) node).getWidth() : 0); double anchorY = parent.getY() + origin.getY() + scene.getY() + (vAlign == PopupVPosition.BOTTOM ? ((Region) node).getHeight() : 0); if (attachToNode) this.show(node, anchorX, anchorY); else this.show(parent, anchorX, anchorY); ((JFXPopupSkin) getSkin()).reset(vAlign, isRTL ? hAlign.getOpposite() : hAlign, isRTL ? -initOffsetX : initOffsetX, initOffsetY); Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate()); } } public void show(Window window, double x, double y, PopupVPosition vAlign, PopupHPosition hAlign, double initOffsetX, double initOffsetY) { if (!isShowing()) { if (window == null) { throw new IllegalStateException("Can not show popup. The node must be attached to a scene/window."); } Window parent = window; final double anchorX = parent.getX() + x + initOffsetX; final double anchorY = parent.getY() + y + initOffsetY; this.show(parent, anchorX, anchorY); ((JFXPopupSkin) getSkin()).reset(vAlign, hAlign, initOffsetX, initOffsetY); Platform.runLater(() -> ((JFXPopupSkin) getSkin()).animate()); } } @Override public void hide() { super.hide(); ((JFXPopupSkin) getSkin()).init(); } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ /// Initialize the style class to 'jfx-popup'. /// /// This is the selector class from which CSS can be used to style /// this control. private static final String DEFAULT_STYLE_CLASS = "jfx-popup"; } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXProgressBar.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXProgressBarSkin; import javafx.scene.control.ProgressBar; import javafx.scene.control.Skin; /// JFXProgressBar is the material design implementation of a progress bar. /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXProgressBar extends ProgressBar { /// Initialize the style class to 'jfx-progress-bar'. /// /// This is the selector class from which CSS can be used to style /// this control. private static final String DEFAULT_STYLE_CLASS = "jfx-progress-bar"; public JFXProgressBar() { initialize(); } public JFXProgressBar(double progress) { super(progress); initialize(); } @Override protected Skin createDefaultSkin() { return new JFXProgressBarSkin(this); } private boolean smoothProgress = true; public boolean isSmoothProgress() { return smoothProgress; } public void setSmoothProgress(boolean smoothProgress) { this.smoothProgress = smoothProgress; } private void initialize() { setPrefWidth(200); getStyleClass().add(DEFAULT_STYLE_CLASS); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXRadioButton.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXRadioButtonSkin; import javafx.css.*; import javafx.css.converter.ColorConverter; import javafx.scene.control.RadioButton; import javafx.scene.control.Skin; import javafx.scene.paint.Color; import java.util.ArrayList; import java.util.Collections; import java.util.List; /// JFXRadioButton is the material design implementation of a radio button. /// /// @author Bashir Elias & Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXRadioButton extends RadioButton { public JFXRadioButton(String text) { super(text); initialize(); } public JFXRadioButton() { initialize(); } @Override protected Skin createDefaultSkin() { return new JFXRadioButtonSkin(this); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } /// Initialize the style class to 'jfx-radio-button'. /// /// This is the selector class from which CSS can be used to style /// this control. private static final String DEFAULT_STYLE_CLASS = "jfx-radio-button"; /// default color used when the radio button is selected private StyleableObjectProperty selectedColor; private static final Color DEFAULT_SELECTED_COLOR = Color.valueOf("#0F9D58"); public final StyleableObjectProperty selectedColorProperty() { if (selectedColor == null) { selectedColor = new SimpleStyleableObjectProperty<>(StyleableProperties.SELECTED_COLOR, JFXRadioButton.this, "selectedColor", DEFAULT_SELECTED_COLOR); } return this.selectedColor; } public final Color getSelectedColor() { return selectedColor == null ? DEFAULT_SELECTED_COLOR : this.selectedColorProperty().get(); } public final void setSelectedColor(final Color selectedColor) { this.selectedColorProperty().set(selectedColor); } /// default color used when the radio button is not selected private StyleableObjectProperty unSelectedColor; private static final Color DEFAULT_UNSELECTED_COLOR = Color.valueOf("#5A5A5A"); public final StyleableObjectProperty unSelectedColorProperty() { if (unSelectedColor == null) { unSelectedColor = new SimpleStyleableObjectProperty<>( StyleableProperties.UNSELECTED_COLOR, JFXRadioButton.this, "unSelectedColor", DEFAULT_UNSELECTED_COLOR); } return this.unSelectedColor; } public final Color getUnSelectedColor() { return unSelectedColor == null ? DEFAULT_UNSELECTED_COLOR : this.unSelectedColorProperty().get(); } public final void setUnSelectedColor(final Color unSelectedColor) { this.unSelectedColorProperty().set(unSelectedColor); } private static final class StyleableProperties { private static final CssMetaData SELECTED_COLOR = new CssMetaData("-jfx-selected-color", ColorConverter.getInstance(), DEFAULT_SELECTED_COLOR) { @Override public boolean isSettable(JFXRadioButton control) { return control.selectedColor == null || !control.selectedColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXRadioButton control) { return control.selectedColorProperty(); } }; private static final CssMetaData UNSELECTED_COLOR = new CssMetaData("-jfx-unselected-color", ColorConverter.getInstance(), DEFAULT_UNSELECTED_COLOR) { @Override public boolean isSettable(JFXRadioButton control) { return control.unSelectedColor == null || !control.unSelectedColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXRadioButton control) { return control.unSelectedColorProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(RadioButton.getClassCssMetaData()); Collections.addAll(styleables, SELECTED_COLOR, UNSELECTED_COLOR ); CHILD_STYLEABLES = List.copyOf(styleables); } } @Override public List> getControlCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXRippler.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.converters.RipplerMaskTypeConverter; import com.jfoenix.utils.JFXNodeUtils; import javafx.animation.*; import javafx.beans.DefaultProperty; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.css.*; import javafx.css.converter.BooleanConverter; import javafx.css.converter.PaintConverter; import javafx.css.converter.SizeConverter; import javafx.geometry.Bounds; import javafx.scene.CacheHint; import javafx.scene.Group; import javafx.scene.Node; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.scene.shape.Circle; import javafx.scene.shape.Rectangle; import javafx.scene.shape.Shape; import javafx.util.Duration; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; /** * JFXRippler is the material design implementation of a ripple effect. * the ripple effect can be applied to any node in the scene. JFXRippler is * a {@link StackPane} container that holds a specified node (control node) and a ripple generator. *

* UPDATE NOTES: * - fireEventProgrammatically(Event) method has been removed as the ripple controller is * the control itself, so you can trigger manual ripple by firing mouse event on the control * instead of JFXRippler * * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ @DefaultProperty(value = "control") public class JFXRippler extends StackPane { public enum RipplerPos { FRONT, BACK } public enum RipplerMask { CIRCLE, RECT, FIT } protected RippleGenerator rippler; protected Pane ripplerPane; protected Node control; protected static final double RIPPLE_MAX_RADIUS = 300; private static final Interpolator RIPPLE_INTERPOLATOR = Interpolator.SPLINE(0.0825, 0.3025, 0.0875, 0.9975); //0.1, 0.54, 0.28, 0.95); private boolean forceOverlay = false; /// creates empty rippler node public JFXRippler() { this(null, RipplerMask.RECT, RipplerPos.FRONT); } /// creates a rippler for the specified control public JFXRippler(Node control) { this(control, RipplerMask.RECT, RipplerPos.FRONT); } /// creates a rippler for the specified control /// /// @param pos can be either FRONT/BACK (position the ripple effect infront of or behind the control) public JFXRippler(Node control, RipplerPos pos) { this(control, RipplerMask.RECT, pos); } /// creates a rippler for the specified control and apply the specified mask to it /// /// @param mask can be either rectangle/cricle public JFXRippler(Node control, RipplerMask mask) { this(control, mask, RipplerPos.FRONT); } /// creates a rippler for the specified control, mask and position. /// /// @param mask can be either rectangle/cricle /// @param pos can be either FRONT/BACK (position the ripple effect infront of or behind the control) public JFXRippler(Node control, RipplerMask mask, RipplerPos pos) { initialize(); setMaskType(mask); setPosition(pos); createRippleUI(); setControl(control); // listen to control position changed position.addListener(observable -> updateControlPosition()); setPickOnBounds(false); setCache(true); setCacheHint(CacheHint.SPEED); setCacheShape(true); } protected final void createRippleUI() { // create rippler panels rippler = new RippleGenerator(); ripplerPane = new StackPane(); ripplerPane.setMouseTransparent(true); ripplerPane.getChildren().add(rippler); getChildren().add(ripplerPane); } /*************************************************************************** * * * Setters / Getters * * * **************************************************************************/ public void setControl(Node control) { if (control != null) { this.control = control; // position control positionControl(control); // add control listeners to generate / release ripples initControlListeners(); } } // Override this method to create JFXRippler for a control outside the ripple protected void positionControl(Node control) { if (this.position.get() == RipplerPos.BACK) { getChildren().add(control); } else { getChildren().add(0, control); } } protected void updateControlPosition() { if (this.position.get() == RipplerPos.BACK) { ripplerPane.toBack(); } else { ripplerPane.toFront(); } } public Node getControl() { return control; } // methods that can be changed by extending the rippler class /// generate the clipping mask /// /// @return the mask node protected Node getMask() { double borderWidth = ripplerPane.getBorder() != null ? ripplerPane.getBorder().getInsets().getTop() : 0; Bounds bounds = control.getBoundsInParent(); double width = control.getLayoutBounds().getWidth(); double height = control.getLayoutBounds().getHeight(); double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX()); double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY()); double diffMaxX = Math.abs(control.getBoundsInLocal().getMaxX() - control.getLayoutBounds().getMaxX()); double diffMaxY = Math.abs(control.getBoundsInLocal().getMaxY() - control.getLayoutBounds().getMaxY()); Node mask; switch (getMaskType()) { case RECT: mask = new Rectangle(bounds.getMinX() + diffMinX - snappedLeftInset(), bounds.getMinY() + diffMinY - snappedTopInset(), width - 2 * borderWidth, height - 2 * borderWidth); // -0.1 to prevent resizing the anchor pane break; case CIRCLE: double radius = Math.min((width / 2) - 2 * borderWidth, (height / 2) - 2 * borderWidth); mask = new Circle((bounds.getMinX() + diffMinX + bounds.getMaxX() - diffMaxX) / 2 - snappedLeftInset(), (bounds.getMinY() + diffMinY + bounds.getMaxY() - diffMaxY) / 2 - snappedTopInset(), radius, Color.BLUE); break; case FIT: mask = new Region(); if (control instanceof Shape) { ((Region) mask).setShape((Shape) control); } else if (control instanceof Region) { ((Region) mask).setShape(((Region) control).getShape()); JFXNodeUtils.updateBackground(((Region) control).getBackground(), (Region) mask); } mask.resize(width, height); mask.relocate(bounds.getMinX() + diffMinX, bounds.getMinY() + diffMinY); break; default: mask = new Rectangle(bounds.getMinX() + diffMinX - snappedLeftInset(), bounds.getMinY() + diffMinY - snappedTopInset(), width - 2 * borderWidth, height - 2 * borderWidth); // -0.1 to prevent resizing the anchor pane break; } return mask; } /** * compute the ripple radius * * @return the ripple radius size */ protected double computeRippleRadius() { double width2 = control.getLayoutBounds().getWidth() * control.getLayoutBounds().getWidth(); double height2 = control.getLayoutBounds().getHeight() * control.getLayoutBounds().getHeight(); return Math.min(Math.sqrt(width2 + height2), RIPPLE_MAX_RADIUS) * 1.1 + 5; } protected void setOverLayBounds(Rectangle overlay) { overlay.setWidth(control.getLayoutBounds().getWidth()); overlay.setHeight(control.getLayoutBounds().getHeight()); } /** * init mouse listeners on the control */ protected void initControlListeners() { // if the control got resized the overlay rect must be rest control.layoutBoundsProperty().addListener(observable -> resetRippler()); if (getChildren().contains(control)) { control.boundsInParentProperty().addListener(observable -> resetRippler()); } control.addEventHandler(MouseEvent.MOUSE_PRESSED, event -> { if (event.getButton() == MouseButton.PRIMARY) createRipple(event.getX(), event.getY()); }); // create fade out transition for the ripple control.addEventHandler(MouseEvent.MOUSE_RELEASED, event -> { if (event.getButton() == MouseButton.PRIMARY) releaseRipple(); }); } /** * creates Ripple effect */ protected void createRipple(double x, double y) { if (!isRipplerDisabled()) { rippler.setGeneratorCenterX(x); rippler.setGeneratorCenterY(y); rippler.createRipple(); } } protected void releaseRipple() { rippler.releaseRipple(); } /** * creates Ripple effect in the center of the control * * @return a runnable to release the ripple when needed */ public Runnable createManualRipple() { if (!isRipplerDisabled()) { rippler.setGeneratorCenterX(control.getLayoutBounds().getWidth() / 2); rippler.setGeneratorCenterY(control.getLayoutBounds().getHeight() / 2); rippler.createRipple(); return () -> { // create fade out transition for the ripple releaseRipple(); }; } return () -> { }; } /// show/hide the ripple overlay /// /// @param forceOverlay used to hold the overlay after ripple action public void setOverlayVisible(boolean visible, boolean forceOverlay) { this.forceOverlay = forceOverlay; setOverlayVisible(visible); } /// show/hide the ripple overlay /// NOTE: setting overlay visibility to false will reset forceOverlay to false public void setOverlayVisible(boolean visible) { if (visible) { showOverlay(); } else { forceOverlay = false; hideOverlay(); } } /** * this method will be set to private in future versions of JFoenix, * user the method {@link #setOverlayVisible(boolean)} */ public void showOverlay() { if (rippler.overlayRect != null) { rippler.overlayRect.outAnimation.stop(); } rippler.createOverlay(); rippler.overlayRect.inAnimation.play(); } public void hideOverlay() { if (!forceOverlay) { if (rippler.overlayRect != null) { rippler.overlayRect.inAnimation.stop(); } if (rippler.overlayRect != null) { rippler.overlayRect.outAnimation.play(); } } else { System.err.println("Ripple Overlay is forced!"); } } /** * Generates ripples on the screen every 0.3 seconds or whenever * the createRipple method is called. Ripples grow and fade out * over 0.6 seconds */ protected final class RippleGenerator extends Group { private double generatorCenterX = 0; private double generatorCenterY = 0; private OverLayRipple overlayRect; private final AtomicBoolean generating = new AtomicBoolean(false); private boolean cacheRipplerClip = false; private boolean resetClip = false; private final Queue ripplesQueue = new LinkedList<>(); RippleGenerator() { // improve in performance, by preventing // redrawing the parent when the ripple effect is triggered this.setManaged(false); this.setCache(true); this.setCacheHint(CacheHint.SPEED); } void createRipple() { if (!generating.getAndSet(true)) { // create overlay once then change its color later createOverlay(); if (this.getClip() == null || (getChildren().size() == 1 && !cacheRipplerClip) || resetClip) { this.setClip(getMask()); } this.resetClip = false; // create the ripple effect final Ripple ripple = new Ripple(generatorCenterX, generatorCenterY); getChildren().add(ripple); ripplesQueue.add(ripple); // animate the ripple overlayRect.outAnimation.stop(); overlayRect.inAnimation.play(); ripple.inAnimation.play(); } } private void releaseRipple() { Ripple ripple = ripplesQueue.poll(); if (ripple != null) { ripple.inAnimation.stop(); ripple.outAnimation = new Timeline( new KeyFrame(Duration.millis(Math.min(800, (0.9 * 500) / ripple.getScaleX())) , ripple.outKeyValues)); ripple.outAnimation.setOnFinished((event) -> getChildren().remove(ripple)); ripple.outAnimation.play(); if (generating.getAndSet(false)) { if (overlayRect != null) { overlayRect.inAnimation.stop(); if (!forceOverlay) { overlayRect.outAnimation.play(); } } } } } void cacheRippleClip(boolean cached) { cacheRipplerClip = cached; } void createOverlay() { if (overlayRect == null) { overlayRect = new OverLayRipple(); overlayRect.setClip(getMask()); getChildren().add(0, overlayRect); overlayRect.fillProperty().bind(Bindings.createObjectBinding(() -> { if (getRipplerFill() instanceof Color fill) { return new Color(fill.getRed(), fill.getGreen(), fill.getBlue(), 0.2); } else { return Color.TRANSPARENT; } }, ripplerFillProperty())); } } void setGeneratorCenterX(double generatorCenterX) { this.generatorCenterX = generatorCenterX; } void setGeneratorCenterY(double generatorCenterY) { this.generatorCenterY = generatorCenterY; } private final class OverLayRipple extends Rectangle { // Overlay ripple animations Animation inAnimation = new Timeline(new KeyFrame(Duration.millis(300), new KeyValue(opacityProperty(), 1, Interpolator.EASE_IN))); Animation outAnimation = new Timeline(new KeyFrame(Duration.millis(300), new KeyValue(opacityProperty(), 0, Interpolator.EASE_OUT))); OverLayRipple() { super(); setOverLayBounds(this); this.getStyleClass().add("jfx-rippler-overlay"); // update initial position if (JFXRippler.this.getChildrenUnmodifiable().contains(control)) { double diffMinX = Math.abs(control.getBoundsInLocal().getMinX() - control.getLayoutBounds().getMinX()); double diffMinY = Math.abs(control.getBoundsInLocal().getMinY() - control.getLayoutBounds().getMinY()); Bounds bounds = control.getBoundsInParent(); this.setX(bounds.getMinX() + diffMinX - snappedLeftInset()); this.setY(bounds.getMinY() + diffMinY - snappedTopInset()); } // set initial attributes setOpacity(0); setCache(true); setCacheHint(CacheHint.SPEED); setCacheShape(true); setManaged(false); } } private final class Ripple extends Circle { KeyValue[] outKeyValues; Animation outAnimation = null; Animation inAnimation = null; private Ripple(double centerX, double centerY) { super(centerX, centerY, getRipplerRadius() == Region.USE_COMPUTED_SIZE ? computeRippleRadius() : getRipplerRadius(), null); setCache(true); setCacheHint(CacheHint.SPEED); setCacheShape(true); setManaged(false); setSmooth(true); KeyValue[] inKeyValues = new KeyValue[isRipplerRecenter() ? 4 : 2]; outKeyValues = new KeyValue[isRipplerRecenter() ? 5 : 3]; inKeyValues[0] = new KeyValue(scaleXProperty(), 0.9, RIPPLE_INTERPOLATOR); inKeyValues[1] = new KeyValue(scaleYProperty(), 0.9, RIPPLE_INTERPOLATOR); outKeyValues[0] = new KeyValue(this.scaleXProperty(), 1, RIPPLE_INTERPOLATOR); outKeyValues[1] = new KeyValue(this.scaleYProperty(), 1, RIPPLE_INTERPOLATOR); outKeyValues[2] = new KeyValue(this.opacityProperty(), 0, RIPPLE_INTERPOLATOR); if (isRipplerRecenter()) { double dx = (control.getLayoutBounds().getWidth() / 2 - centerX) / 1.55; double dy = (control.getLayoutBounds().getHeight() / 2 - centerY) / 1.55; inKeyValues[2] = outKeyValues[3] = new KeyValue(translateXProperty(), Math.signum(dx) * Math.min(Math.abs(dx), this.getRadius() / 2), RIPPLE_INTERPOLATOR); inKeyValues[3] = outKeyValues[4] = new KeyValue(translateYProperty(), Math.signum(dy) * Math.min(Math.abs(dy), this.getRadius() / 2), RIPPLE_INTERPOLATOR); } inAnimation = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(scaleXProperty(), 0, RIPPLE_INTERPOLATOR), new KeyValue(scaleYProperty(), 0, RIPPLE_INTERPOLATOR), new KeyValue(translateXProperty(), 0, RIPPLE_INTERPOLATOR), new KeyValue(translateYProperty(), 0, RIPPLE_INTERPOLATOR), new KeyValue(opacityProperty(), 1, RIPPLE_INTERPOLATOR) ), new KeyFrame(Duration.millis(900), inKeyValues)); setScaleX(0); setScaleY(0); if (getRipplerFill() instanceof Color fill) { Color circleColor = new Color(fill.getRed(), fill.getGreen(), fill.getBlue(), 0.3); setStroke(circleColor); setFill(circleColor); } else { setStroke(getRipplerFill()); setFill(getRipplerFill()); } } } public void clear() { getChildren().clear(); rippler.overlayRect = null; generating.set(false); } } private void resetOverLay() { if (rippler.overlayRect != null) { rippler.overlayRect.inAnimation.stop(); final RippleGenerator.OverLayRipple oldOverlay = rippler.overlayRect; rippler.overlayRect.outAnimation.setOnFinished((finish) -> rippler.getChildren().remove(oldOverlay)); rippler.overlayRect.outAnimation.play(); rippler.overlayRect = null; } } private void resetClip() { this.rippler.resetClip = true; } protected void resetRippler() { resetOverLay(); resetClip(); } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ /** * Initialize the style class to 'jfx-rippler'. *

* This is the selector class from which CSS can be used to style * this control. */ private static final String DEFAULT_STYLE_CLASS = "jfx-rippler"; private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } /** * the ripple recenter property, by default it's false. * if true the ripple effect will show gravitational pull to the center of its control */ private StyleableBooleanProperty ripplerRecenter; public boolean isRipplerRecenter() { return ripplerRecenter != null && ripplerRecenter.get(); } public StyleableBooleanProperty ripplerRecenterProperty() { if (this.ripplerRecenter == null) { this.ripplerRecenter = new SimpleStyleableBooleanProperty( StyleableProperties.RIPPLER_RECENTER, JFXRippler.this, "ripplerRecenter", false); } return this.ripplerRecenter; } public void setRipplerRecenter(boolean recenter) { ripplerRecenterProperty().set(recenter); } /** * the ripple radius size, by default it will be automatically computed. */ private StyleableDoubleProperty ripplerRadius; public double getRipplerRadius() { return ripplerRadius == null ? Region.USE_COMPUTED_SIZE : ripplerRadius.get(); } public StyleableDoubleProperty ripplerRadiusProperty() { if (this.ripplerRadius == null) { this.ripplerRadius = new SimpleStyleableDoubleProperty( StyleableProperties.RIPPLER_RADIUS, JFXRippler.this, "ripplerRadius", Region.USE_COMPUTED_SIZE); } return this.ripplerRadius; } public void setRipplerRadius(double radius) { ripplerRadiusProperty().set(radius); } private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255); /** * the default color of the ripple effect */ private StyleableObjectProperty ripplerFill; public Paint getRipplerFill() { return ripplerFill == null ? DEFAULT_RIPPLER_FILL : ripplerFill.get(); } public StyleableObjectProperty ripplerFillProperty() { if (this.ripplerFill == null) { this.ripplerFill = new SimpleStyleableObjectProperty<>(StyleableProperties.RIPPLER_FILL, JFXRippler.this, "ripplerFill", DEFAULT_RIPPLER_FILL); } return this.ripplerFill; } public void setRipplerFill(Paint color) { ripplerFillProperty().set(color); } /// mask property used for clipping the rippler. /// can be either CIRCLE/RECT private StyleableObjectProperty maskType; public RipplerMask getMaskType() { return maskType == null ? RipplerMask.RECT : maskType.get(); } public StyleableObjectProperty maskTypeProperty() { if (this.maskType == null) { this.maskType = new SimpleStyleableObjectProperty<>( StyleableProperties.MASK_TYPE, JFXRippler.this, "maskType", RipplerMask.RECT); } return this.maskType; } public void setMaskType(RipplerMask type) { if (this.maskType != null || type != RipplerMask.RECT) maskTypeProperty().set(type); } /** * the ripple disable, by default it's false. * if true the ripple effect will be hidden */ private StyleableBooleanProperty ripplerDisabled; public boolean isRipplerDisabled() { return ripplerDisabled != null && ripplerDisabled.get(); } public StyleableBooleanProperty ripplerDisabledProperty() { if (this.ripplerDisabled == null) { this.ripplerDisabled = new SimpleStyleableBooleanProperty( StyleableProperties.RIPPLER_DISABLED, JFXRippler.this, "ripplerDisabled", false); } return this.ripplerDisabled; } public void setRipplerDisabled(boolean disabled) { ripplerDisabledProperty().set(disabled); } /** * indicates whether the ripple effect is infront of or behind the node */ protected ObjectProperty position = new SimpleObjectProperty<>(); public void setPosition(RipplerPos pos) { this.position.set(pos); } public RipplerPos getPosition() { return position == null ? RipplerPos.FRONT : position.get(); } public ObjectProperty positionProperty() { return this.position; } private static final class StyleableProperties { private static final CssMetaData RIPPLER_RECENTER = new CssMetaData<>("-jfx-rippler-recenter", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXRippler control) { return control.ripplerRecenter == null || !control.ripplerRecenter.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXRippler control) { return control.ripplerRecenterProperty(); } }; private static final CssMetaData RIPPLER_DISABLED = new CssMetaData<>("-jfx-rippler-disabled", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXRippler control) { return control.ripplerDisabled == null || !control.ripplerDisabled.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXRippler control) { return control.ripplerDisabledProperty(); } }; private static final CssMetaData RIPPLER_FILL = new CssMetaData<>("-jfx-rippler-fill", PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) { @Override public boolean isSettable(JFXRippler control) { return control.ripplerFill == null || !control.ripplerFill.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXRippler control) { return control.ripplerFillProperty(); } }; private static final CssMetaData RIPPLER_RADIUS = new CssMetaData<>("-jfx-rippler-radius", SizeConverter.getInstance(), Region.USE_COMPUTED_SIZE) { @Override public boolean isSettable(JFXRippler control) { return control.ripplerRadius == null || !control.ripplerRadius.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXRippler control) { return control.ripplerRadiusProperty(); } }; private static final CssMetaData MASK_TYPE = new CssMetaData<>("-jfx-mask-type", RipplerMaskTypeConverter.getInstance(), RipplerMask.RECT) { @Override public boolean isSettable(JFXRippler control) { return control.maskType == null || !control.maskType.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXRippler control) { return control.maskTypeProperty(); } }; private static final List> STYLEABLES; static { final List> styleables = new ArrayList<>(StackPane.getClassCssMetaData()); Collections.addAll(styleables, RIPPLER_RECENTER, RIPPLER_RADIUS, RIPPLER_FILL, MASK_TYPE, RIPPLER_DISABLED ); STYLEABLES = Collections.unmodifiableList(styleables); } } @Override public List> getCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXSlider.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.converters.IndicatorPositionConverter; import com.jfoenix.skins.JFXSliderSkin; import javafx.beans.binding.StringBinding; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.css.*; import javafx.scene.control.Skin; import javafx.scene.control.Slider; import javafx.util.Callback; import java.util.ArrayList; import java.util.Collections; import java.util.List; /// JFXSlider is the material design implementation of a slider. /// /// @author Bashir Elias & Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXSlider extends Slider { public JFXSlider() { super(0, 100, 50); initialize(); } public JFXSlider(double min, double max, double value) { super(min, max, value); initialize(); } @Override protected Skin createDefaultSkin() { return new JFXSliderSkin(this); } private void initialize() { getStyleClass().add(DEFAULT_STYLE_CLASS); } public enum IndicatorPosition { LEFT, RIGHT } /*************************************************************************** * * * Properties * * * **************************************************************************/ /// String binding factory for the slider value. /// Sets a custom string for the value text (by default, it shows the value rounded to the nearest whole number). /// /// /// For example, to have the value displayed as a percentage (assuming the slider has a range of (0, 100)): /// ```java /// JFXSlider mySlider = ... /// mySlider.setValueFactory(slider -> /// Bindings.createStringBinding( /// () -> ((int) slider.getValue()) + "%", /// slider.valueProperty() /// ) /// ); /// ``` /// /// NOTE: might be replaced later with a call back to create the animated thumb node /// /// @param callback a callback to create the string value binding private ObjectProperty> valueFactory; public final ObjectProperty> valueFactoryProperty() { if (valueFactory == null) { valueFactory = new SimpleObjectProperty<>(this, "valueFactory"); } return valueFactory; } /// @return the current slider value factory public final Callback getValueFactory() { return valueFactory == null ? null : valueFactory.get(); } /// sets custom string binding for the slider text value /// /// @param valueFactory a callback to create the string value binding public final void setValueFactory(final Callback valueFactory) { this.valueFactoryProperty().set(valueFactory); } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ /// Initialize the style class to 'jfx-slider'. /// /// This is the selector class from which CSS can be used to style /// this control. private static final String DEFAULT_STYLE_CLASS = "jfx-slider"; /// indicates the position of the slider indicator, can be /// either LEFT or RIGHT private StyleableObjectProperty indicatorPosition; public StyleableObjectProperty indicatorPositionProperty() { if (indicatorPosition == null) { indicatorPosition = new SimpleStyleableObjectProperty<>( StyleableProperties.INDICATOR_POSITION, JFXSlider.this, "indicatorPosition", IndicatorPosition.LEFT); } return this.indicatorPosition; } public IndicatorPosition getIndicatorPosition() { return indicatorPosition == null ? IndicatorPosition.LEFT : indicatorPosition.get(); } public void setIndicatorPosition(IndicatorPosition pos) { indicatorPositionProperty().set(pos); } private static final class StyleableProperties { private static final CssMetaData INDICATOR_POSITION = new CssMetaData<>( "-jfx-indicator-position", IndicatorPositionConverter.getInstance(), IndicatorPosition.LEFT) { @Override public boolean isSettable(JFXSlider control) { return control.indicatorPosition == null || !control.indicatorPosition.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXSlider control) { return control.indicatorPositionProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>( Slider.getClassCssMetaData()); Collections.addAll(styleables, INDICATOR_POSITION); CHILD_STYLEABLES = List.copyOf(styleables); } } @Override public List> getControlCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXSnackbar.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.PauseTransition; import javafx.animation.Timeline; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.beans.value.WeakChangeListener; import javafx.css.PseudoClass; import javafx.event.Event; import javafx.event.EventType; import javafx.geometry.Bounds; import javafx.scene.Group; import javafx.scene.Node; import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.util.Duration; import org.jackhuang.hmcl.ui.animation.Motion; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicBoolean; /// "Snackbars provide brief messages about app processes at the bottom of the screen" /// (Material Design Guidelines). /// /// To show a snackbar you need to ///

    /// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in /// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method. /// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your /// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended. /// /// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty, /// but any arbitrary [Node] will do. /// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the /// duration. ///
/// /// Finally, with all those things prepared, show your snackbar using /// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)]. /// /// It's most convenient to create functions to do most of this (creating the layout and event) with the default /// settings; that way all you need to do to show a snackbar is specify the message or just the message and the duration. /// /// @see The Material Design Snackbar public class JFXSnackbar extends Group { private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar"; private Pane snackbarContainer; private final ChangeListener sizeListener = (o, oldVal, newVal) -> refreshPopup(); private final WeakChangeListener weakSizeListener = new WeakChangeListener<>(sizeListener); private final AtomicBoolean processingQueue = new AtomicBoolean(false); private final ConcurrentLinkedQueue eventQueue = new ConcurrentLinkedQueue<>(); private final ConcurrentHashMap.KeySetView eventsSet = ConcurrentHashMap.newKeySet(); private final Pane content; private PseudoClass activePseudoClass = null; private PauseTransition pauseTransition; /// This constructor assumes that you will eventually call the [#registerSnackbarContainer(Pane)] method before /// calling the [#enqueue(SnackbarEvent)] method. Otherwise, how will the snackbar know where to show itself? /// /// /// "Snackbars provide brief messages about app processes at the bottom of the screen" /// (Material Design Guidelines). /// /// To show a snackbar you need to /// /// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in /// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method. /// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your /// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended. /// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty, /// but any arbitrary [Node] will do. /// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the /// duration. /// /// Finally, with all those things prepared, show your snackbar using /// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)]. /// public JFXSnackbar() { this(null); } /// "Snackbars provide brief messages about app processes at the bottom of the screen" /// (Material Design Guidelines). /// /// To show a snackbar you need to /// /// - Have a [Pane] (snackbarContainer) to show the snackbar on top of. Register it in /// [the JFXSnackbar constructor][#JFXSnackbar(Pane)] or using the [#registerSnackbarContainer(Pane)] method. /// - Have or create a [JFXSnackbar]. - Having one snackbar where you pass all your /// [SnackbarEvents][JFXSnackbar.SnackbarEvent] will ensure that the [enqueuemethod][JFXSnackbar#enqueue(SnackbarEvent)] works as intended. /// - Have something to show in the snackbar. A [JFXSnackbarLayout] is nice and pretty, /// but any arbitrary [Node] will do. /// - Create a [SnackbarEvent][JFXSnackbar.SnackbarEvent] specifying the contents and the /// duration. /// /// Finally, with all those things prepared, show your snackbar using /// [snackbar.enqueue(snackbarEvent);][JFXSnackbar#enqueue(SnackbarEvent)]. /// /// @param snackbarContainer where the snackbar will appear. Using a single snackbar instead of many, will ensure that /// the [#enqueue(SnackbarEvent)] method works correctly. public JFXSnackbar(Pane snackbarContainer) { initialize(); content = new StackPane(); content.getStyleClass().add("jfx-snackbar-content"); //wrap the content in a group so that the content is managed inside its own container //but the group is not managed in the snackbarContainer so it does not affect any layout calculations getChildren().add(content); setManaged(false); setVisible(false); // register the container before resizing it registerSnackbarContainer(snackbarContainer); // resize the popup if its layout has been changed layoutBoundsProperty().addListener((o, oldVal, newVal) -> refreshPopup()); addEventHandler(SnackbarEvent.SNACKBAR, this::enqueue); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } // Setters / Getters public Pane getPopupContainer() { return snackbarContainer; } public void setPrefWidth(double width) { content.setPrefWidth(width); } public double getPrefWidth() { return content.getPrefWidth(); } // Public API public void registerSnackbarContainer(Pane snackbarContainer) { if (snackbarContainer != null) { if (this.snackbarContainer != null) { //since listeners are added the container should be properly registered/unregistered throw new IllegalArgumentException("Snackbar Container already set"); } this.snackbarContainer = snackbarContainer; this.snackbarContainer.getChildren().add(this); this.snackbarContainer.heightProperty().addListener(weakSizeListener); this.snackbarContainer.widthProperty().addListener(weakSizeListener); } } public void unregisterSnackbarContainer(Pane snackbarContainer) { if (snackbarContainer != null) { if (this.snackbarContainer == null) { throw new IllegalArgumentException("Snackbar Container not set"); } this.snackbarContainer.getChildren().remove(this); this.snackbarContainer.heightProperty().removeListener(weakSizeListener); this.snackbarContainer.widthProperty().removeListener(weakSizeListener); this.snackbarContainer = null; } } private void show(SnackbarEvent event) { content.getChildren().setAll(event.getContent()); openAnimation = getTimeline(event.getTimeout()); if (event.getPseudoClass() != null) { activePseudoClass = event.getPseudoClass(); content.pseudoClassStateChanged(activePseudoClass, true); } openAnimation.play(); } private Timeline openAnimation = null; private Timeline getTimeline(Duration timeout) { Timeline animation; animation = new Timeline( new KeyFrame( Duration.ZERO, e -> { this.toBack(); this.setVisible(false); }, new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE), new KeyValue(this.opacityProperty(), 0, Motion.EASE) ), new KeyFrame( Duration.millis(10), e -> { this.toFront(); this.setVisible(true); } ), new KeyFrame(Duration.millis(300), new KeyValue(this.opacityProperty(), 1, Motion.EASE), new KeyValue(this.translateYProperty(), 0, Motion.EASE) ) ); animation.setCycleCount(1); pauseTransition = Duration.INDEFINITE.equals(timeout) ? null : new PauseTransition(timeout); if (pauseTransition != null) { animation.setOnFinished(finish -> { pauseTransition.setOnFinished(done -> { pauseTransition = null; eventsSet.remove(currentEvent); currentEvent = eventQueue.peek(); close(); }); pauseTransition.play(); }); } return animation; } public void close() { if (openAnimation != null) { openAnimation.stop(); } if (this.isVisible()) { Timeline closeAnimation = new Timeline( new KeyFrame( Duration.ZERO, e -> this.toFront(), new KeyValue(this.opacityProperty(), 1, Motion.EASE), new KeyValue(this.translateYProperty(), 0, Motion.EASE) ), new KeyFrame( Duration.millis(290), e -> this.setVisible(true) ), new KeyFrame(Duration.millis(300), e -> { this.toBack(); this.setVisible(false); }, new KeyValue(this.translateYProperty(), this.getLayoutBounds().getHeight(), Motion.EASE), new KeyValue(this.opacityProperty(), 0, Motion.EASE) ) ); closeAnimation.setCycleCount(1); closeAnimation.setOnFinished(e -> { resetPseudoClass(); processSnackbar(); }); closeAnimation.play(); } } private SnackbarEvent currentEvent = null; public SnackbarEvent getCurrentEvent() { return currentEvent; } /** * Shows {@link SnackbarEvent SnackbarEvents} one by one. The next event will be shown after the current event's duration. * * @param event the {@link SnackbarEvent event} to put in the queue. */ public void enqueue(SnackbarEvent event) { synchronized (this) { if (!eventsSet.contains(event)) { eventsSet.add(event); eventQueue.offer(event); } else if (currentEvent == event && pauseTransition != null) { pauseTransition.playFromStart(); } } if (processingQueue.compareAndSet(false, true)) { Platform.runLater(() -> { currentEvent = eventQueue.poll(); if (currentEvent != null) { show(currentEvent); } }); } } private void resetPseudoClass() { if (activePseudoClass != null) { content.pseudoClassStateChanged(activePseudoClass, false); activePseudoClass = null; } } private void processSnackbar() { currentEvent = eventQueue.poll(); if (currentEvent != null) { eventsSet.remove(currentEvent); show(currentEvent); } else { //The enqueue method and this listener should be executed sequentially on the FX Thread so there //should not be a race condition processingQueue.getAndSet(false); } } private void refreshPopup() { if (snackbarContainer == null) { return; } Bounds contentBound = this.getLayoutBounds(); double offsetX = Math.ceil(snackbarContainer.getWidth() / 2) - Math.ceil(contentBound.getWidth() / 2); double offsetY = snackbarContainer.getHeight() - contentBound.getHeight(); this.setLayoutX(offsetX); this.setLayoutY(offsetY); } /////////////////////////////////////////////////////////////////////////// // Event API /////////////////////////////////////////////////////////////////////////// /// Specifies _what_ and _how long_ to show a [JFXSnackbar]. /// /// The _what_ can be any arbitrary [Node]; the [JFXSnackbarLayout] is a great choice. /// /// The _how long_ is specified in the form of a [javafx.util.Duration][javafx.util.Duration], not to be /// confused with the [java.time.Duration]. public static class SnackbarEvent extends Event { public static final EventType SNACKBAR = new EventType<>(Event.ANY, "SNACKBAR"); /// The amount of time the snackbar will show for, if not otherwise specified. /// /// It's 1.5 seconds. public static Duration DEFAULT_DURATION = Duration.seconds(1.5); private final Node content; private final PseudoClass pseudoClass; private final Duration timeout; /// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION] and no pseudoClass. /// /// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice. public SnackbarEvent(Node content) { this(content, DEFAULT_DURATION, null); } /// Creates a [SnackbarEvent] with the [default duration][#DEFAULT_DURATION]; you specify the contents and /// pseudoClass. /// /// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice. public SnackbarEvent(Node content, PseudoClass pseudoClass) { this(content, DEFAULT_DURATION, pseudoClass); } /// Creates a SnackbarEvent with no pseudoClass; you specify the contents and duration. /// pseudoClass. /// /// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice. /// @param timeout the amount of time you want the snackbar to show for. public SnackbarEvent(Node content, Duration timeout) { this(content, timeout, null); } /// Creates a SnackbarEvent; you specify the contents, duration and pseudoClass. /// /// If you don't need so much customization, try one of the other constructors. /// /// @param content what you want shown in the snackbar; a [JFXSnackbarLayout] is a great choice. /// @param timeout the amount of time you want the snackbar to show for. public SnackbarEvent(Node content, Duration timeout, PseudoClass pseudoClass) { super(SNACKBAR); this.content = content; this.pseudoClass = pseudoClass; this.timeout = timeout; } public Node getContent() { return content; } public PseudoClass getPseudoClass() { return pseudoClass; } public Duration getTimeout() { return timeout; } @Override @SuppressWarnings("unchecked") public EventType getEventType() { return (EventType) super.getEventType(); } public boolean isPersistent() { return Duration.INDEFINITE.equals(getTimeout()); } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXSnackbarLayout.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import javafx.beans.binding.Bindings; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.scene.control.Control; import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; /// JFXSnackbarLayout default layout for snackbar content /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2018-11-16 public class JFXSnackbarLayout extends BorderPane { private final Label toast; private JFXButton action; private final StackPane actionContainer; public JFXSnackbarLayout(String message) { this(message, null, null); } public JFXSnackbarLayout(String message, String actionText, EventHandler actionHandler) { initialize(); toast = new Label(); toast.setMinWidth(Control.USE_PREF_SIZE); toast.getStyleClass().add("jfx-snackbar-toast"); toast.setWrapText(true); toast.setText(message); StackPane toastContainer = new StackPane(toast); toastContainer.setPadding(new Insets(20)); actionContainer = new StackPane(); actionContainer.setPadding(new Insets(0, 10, 0, 0)); toast.prefWidthProperty().bind(Bindings.createDoubleBinding(() -> { if (getPrefWidth() == -1) { return getPrefWidth(); } double actionWidth = actionContainer.isVisible() ? actionContainer.getWidth() : 0.0; return prefWidthProperty().get() - actionWidth; }, prefWidthProperty(), actionContainer.widthProperty(), actionContainer.visibleProperty())); setLeft(toastContainer); setRight(actionContainer); if (actionText != null) { action = new JFXButton(); action.setText(actionText); action.setOnAction(actionHandler); action.setMinWidth(Control.USE_PREF_SIZE); action.setButtonType(JFXButton.ButtonType.FLAT); action.getStyleClass().add("jfx-snackbar-action"); // actions will be added upon showing the snackbar if needed actionContainer.getChildren().add(action); if (!actionText.isEmpty()) { action.setVisible(true); actionContainer.setVisible(true); actionContainer.setManaged(true); // to force updating the layout bounds action.setText(""); action.setText(actionText); action.setOnAction(actionHandler); } else { actionContainer.setVisible(false); actionContainer.setManaged(false); action.setVisible(false); } } } private static final String DEFAULT_STYLE_CLASS = "jfx-snackbar-layout"; public String getToast() { return toast.getText(); } public void setToast(String toast) { this.toast.setText(toast); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXSpinner.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXSpinnerSkin; import javafx.css.CssMetaData; import javafx.css.SimpleStyleableDoubleProperty; import javafx.css.Styleable; import javafx.css.StyleableDoubleProperty; import javafx.css.converter.SizeConverter; import javafx.scene.control.ProgressIndicator; import javafx.scene.control.Skin; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.ThreadLocalRandom; /// JFXSpinner is the material design implementation of a loading spinner. /// /// @author Bashir Elias & Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXSpinner extends ProgressIndicator { public static final double INDETERMINATE_PROGRESS = ProgressIndicator.INDETERMINATE_PROGRESS; public JFXSpinner() { this(INDETERMINATE_PROGRESS); } public JFXSpinner(double progress) { super(progress); init(); } private void init() { getStyleClass().add(DEFAULT_STYLE_CLASS); } @Override protected Skin createDefaultSkin() { return new JFXSpinnerSkin(this); } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ /// Initialize the style class to 'jfx-spinner'. /// /// This is the selector class from which CSS can be used to style /// this control. private static final String DEFAULT_STYLE_CLASS = "jfx-spinner"; private static final double DEFAULT_RADIUS = 16.0; /** * specifies the radius of the spinner node, by default it's set to `16.0` */ private StyleableDoubleProperty radius; public final StyleableDoubleProperty radiusProperty() { if (this.radius == null) { this.radius = new SimpleStyleableDoubleProperty(StyleableProperties.RADIUS, JFXSpinner.this, "radius", DEFAULT_RADIUS); } return this.radius; } public final double getRadius() { return radius != null ? radius.get() : DEFAULT_RADIUS; } public final void setRadius(final double radius) { this.radiusProperty().set(radius); } public double getStartingAngle() { return 360 - ThreadLocalRandom.current().nextDouble() * 720; } private static final class StyleableProperties { private static final CssMetaData RADIUS = new CssMetaData<>("-jfx-radius", SizeConverter.getInstance(), DEFAULT_RADIUS) { @Override public boolean isSettable(JFXSpinner control) { return control.radius == null || !control.radius.isBound(); } @Override public StyleableDoubleProperty getStyleableProperty(JFXSpinner control) { return control.radiusProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(ProgressIndicator.getClassCssMetaData()); Collections.addAll(styleables, RADIUS); CHILD_STYLEABLES = List.copyOf(styleables); } } @Override public List> getControlCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXTextArea.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXTextAreaSkin; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.*; import javafx.css.converter.BooleanConverter; import javafx.css.converter.PaintConverter; import javafx.scene.control.Control; import javafx.scene.control.Skin; import javafx.scene.control.TextArea; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu; /** * JFXTextArea is the material design implementation of a text area. * * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ public class JFXTextArea extends TextArea { /** * Initialize the style class to 'jfx-text-field'. *

* This is the selector class from which CSS can be used to style * this control. */ private static final String DEFAULT_STYLE_CLASS = "jfx-text-area"; /** * {@inheritDoc} */ public JFXTextArea() { initialize(); } /** * {@inheritDoc} */ public JFXTextArea(String text) { super(text); initialize(); } /** * {@inheritDoc} */ @Override protected Skin createDefaultSkin() { return new JFXTextAreaSkin(this); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) { this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextAreaSkinAndroid\";"); } useJFXContextMenu(this); } /*************************************************************************** * * * Properties * * * **************************************************************************/ /** * holds the current active validator on the text area in case of validation error */ private ReadOnlyObjectWrapper activeValidator = new ReadOnlyObjectWrapper<>(); public ValidatorBase getActiveValidator() { return activeValidator == null ? null : activeValidator.get(); } public ReadOnlyObjectProperty activeValidatorProperty() { return this.activeValidator.getReadOnlyProperty(); } /** * list of validators that will validate the text value upon calling * {{@link #validate()} */ private ObservableList validators = FXCollections.observableArrayList(); public ObservableList getValidators() { return validators; } public void setValidators(ValidatorBase... validators) { this.validators.addAll(validators); } /** * validates the text value using the list of validators provided by the user * {{@link #setValidators(ValidatorBase...)} * * @return true if the value is valid else false */ public boolean validate() { for (ValidatorBase validator : validators) { if (validator.getSrcControl() == null) { validator.setSrcControl(this); } validator.validate(); if (validator.getHasErrors()) { activeValidator.set(validator); return false; } } activeValidator.set(null); return true; } public void resetValidation() { pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false); activeValidator.set(null); } /*************************************************************************** * * * styleable Properties * * * **************************************************************************/ /** * set true to show a float the prompt text when focusing the field */ private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextArea.this, "lableFloat", false); public final StyleableBooleanProperty labelFloatProperty() { return this.labelFloat; } public final boolean isLabelFloat() { return this.labelFloatProperty().get(); } public final void setLabelFloat(final boolean labelFloat) { this.labelFloatProperty().set(labelFloat); } /** * default color used when the text area is unfocused */ private StyleableObjectProperty unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextArea.this, "unFocusColor", Color.rgb(77, 77, 77)); public Paint getUnFocusColor() { return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get(); } public StyleableObjectProperty unFocusColorProperty() { return this.unFocusColor; } public void setUnFocusColor(Paint color) { this.unFocusColor.set(color); } /** * default color used when the text area is focused */ private StyleableObjectProperty focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextArea.this, "focusColor", Color.valueOf("#4059A9")); public Paint getFocusColor() { return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get(); } public StyleableObjectProperty focusColorProperty() { return this.focusColor; } public void setFocusColor(Paint color) { this.focusColor.set(color); } /** * disable animation on validation */ private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextArea.this, "disableAnimation", false); public final StyleableBooleanProperty disableAnimationProperty() { return this.disableAnimation; } public final Boolean isDisableAnimation() { return disableAnimation != null && this.disableAnimationProperty().get(); } public final void setDisableAnimation(final Boolean disabled) { this.disableAnimationProperty().set(disabled); } private final static class StyleableProperties { private static final CssMetaData UNFOCUS_COLOR = new CssMetaData("-jfx-unfocus-color", PaintConverter.getInstance(), Color.rgb(77, 77, 77)) { @Override public boolean isSettable(JFXTextArea control) { return control.unFocusColor == null || !control.unFocusColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXTextArea control) { return control.unFocusColorProperty(); } }; private static final CssMetaData FOCUS_COLOR = new CssMetaData("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#4059A9")) { @Override public boolean isSettable(JFXTextArea control) { return control.focusColor == null || !control.focusColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXTextArea control) { return control.focusColorProperty(); } }; private static final CssMetaData LABEL_FLOAT = new CssMetaData("-jfx-label-float", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXTextArea control) { return control.labelFloat == null || !control.labelFloat.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) { return control.labelFloatProperty(); } }; private static final CssMetaData DISABLE_ANIMATION = new CssMetaData("-jfx-disable-animation", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXTextArea control) { return control.disableAnimation == null || !control.disableAnimation.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXTextArea control) { return control.disableAnimationProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION); CHILD_STYLEABLES = Collections.unmodifiableList(styleables); } } // inherit the styleable properties from parent private List> STYLEABLES; @Override public List> getControlCssMetaData() { if (STYLEABLES == null) { final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); styleables.addAll(getClassCssMetaData()); styleables.addAll(TextArea.getClassCssMetaData()); STYLEABLES = Collections.unmodifiableList(styleables); } return STYLEABLES; } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXTextField.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXTextFieldSkin; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.*; import javafx.css.converter.BooleanConverter; import javafx.css.converter.PaintConverter; import javafx.scene.control.Control; import javafx.scene.control.Skin; import javafx.scene.control.TextField; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import java.util.ArrayList; import java.util.Collections; import java.util.List; import static org.jackhuang.hmcl.ui.FXUtils.useJFXContextMenu; /** * JFXTextField is the material design implementation of a text Field. * * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ public class JFXTextField extends TextField { /** * Initialize the style class to 'jfx-text-field'. *

* This is the selector class from which CSS can be used to style * this control. */ private static final String DEFAULT_STYLE_CLASS = "jfx-text-field"; /** * {@inheritDoc} */ public JFXTextField() { initialize(); } /** * {@inheritDoc} */ public JFXTextField(String text) { super(text); initialize(); } /** * {@inheritDoc} */ @Override protected Skin createDefaultSkin() { return new JFXTextFieldSkin(this); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); if ("dalvik".equalsIgnoreCase(System.getProperty("java.vm.name"))) { this.setStyle("-fx-skin: \"com.jfoenix.android.skins.JFXTextFieldSkinAndroid\";"); } useJFXContextMenu(this); } /*************************************************************************** * * * Properties * * * **************************************************************************/ /** * holds the current active validator on the text field in case of validation error */ private ReadOnlyObjectWrapper activeValidator = new ReadOnlyObjectWrapper<>(); public ValidatorBase getActiveValidator() { return activeValidator == null ? null : activeValidator.get(); } public ReadOnlyObjectProperty activeValidatorProperty() { return this.activeValidator.getReadOnlyProperty(); } /** * list of validators that will validate the text value upon calling * {{@link #validate()} */ private ObservableList validators = FXCollections.observableArrayList(); public ObservableList getValidators() { return validators; } public void setValidators(ValidatorBase... validators) { this.validators.addAll(validators); } /** * validates the text value using the list of validators provided by the user * {{@link #setValidators(ValidatorBase...)} * * @return true if the value is valid else false */ public boolean validate() { for (ValidatorBase validator : validators) { if (validator.getSrcControl() == null) { validator.setSrcControl(this); } validator.validate(); if (validator.getHasErrors()) { activeValidator.set(validator); return false; } } activeValidator.set(null); return true; } public void resetValidation() { pseudoClassStateChanged(ValidatorBase.PSEUDO_CLASS_ERROR, false); activeValidator.set(null); } /*************************************************************************** * * * styleable Properties * * * **************************************************************************/ /** * set true to show a float the prompt text when focusing the field */ private StyleableBooleanProperty labelFloat = new SimpleStyleableBooleanProperty(StyleableProperties.LABEL_FLOAT, JFXTextField.this, "lableFloat", false); public final StyleableBooleanProperty labelFloatProperty() { return this.labelFloat; } public final boolean isLabelFloat() { return this.labelFloatProperty().get(); } public final void setLabelFloat(final boolean labelFloat) { this.labelFloatProperty().set(labelFloat); } /** * default color used when the field is unfocused */ private StyleableObjectProperty unFocusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNFOCUS_COLOR, JFXTextField.this, "unFocusColor", Color.rgb(77, 77, 77)); public Paint getUnFocusColor() { return unFocusColor == null ? Color.rgb(77, 77, 77) : unFocusColor.get(); } public StyleableObjectProperty unFocusColorProperty() { return this.unFocusColor; } public void setUnFocusColor(Paint color) { this.unFocusColor.set(color); } /** * default color used when the field is focused */ private StyleableObjectProperty focusColor = new SimpleStyleableObjectProperty<>(StyleableProperties.FOCUS_COLOR, JFXTextField.this, "focusColor", Color.valueOf("#4059A9")); public Paint getFocusColor() { return focusColor == null ? Color.valueOf("#4059A9") : focusColor.get(); } public StyleableObjectProperty focusColorProperty() { return this.focusColor; } public void setFocusColor(Paint color) { this.focusColor.set(color); } /** * disable animation on validation */ private StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXTextField.this, "disableAnimation", false); public final StyleableBooleanProperty disableAnimationProperty() { return this.disableAnimation; } public final Boolean isDisableAnimation() { return disableAnimation != null && this.disableAnimationProperty().get(); } public final void setDisableAnimation(final Boolean disabled) { this.disableAnimationProperty().set(disabled); } private final static class StyleableProperties { private static final CssMetaData UNFOCUS_COLOR = new CssMetaData("-jfx-unfocus-color", PaintConverter.getInstance(), Color.valueOf("#A6A6A6")) { @Override public boolean isSettable(JFXTextField control) { return control.unFocusColor == null || !control.unFocusColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXTextField control) { return control.unFocusColorProperty(); } }; private static final CssMetaData FOCUS_COLOR = new CssMetaData("-jfx-focus-color", PaintConverter.getInstance(), Color.valueOf("#3f51b5")) { @Override public boolean isSettable(JFXTextField control) { return control.focusColor == null || !control.focusColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXTextField control) { return control.focusColorProperty(); } }; private static final CssMetaData LABEL_FLOAT = new CssMetaData("-jfx-label-float", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXTextField control) { return control.labelFloat == null || !control.labelFloat.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXTextField control) { return control.labelFloatProperty(); } }; private static final CssMetaData DISABLE_ANIMATION = new CssMetaData("-jfx-disable-animation", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXTextField control) { return control.disableAnimation == null || !control.disableAnimation.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXTextField control) { return control.disableAnimationProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); Collections.addAll(styleables, UNFOCUS_COLOR, FOCUS_COLOR, LABEL_FLOAT, DISABLE_ANIMATION); CHILD_STYLEABLES = Collections.unmodifiableList(styleables); } } // inherit the styleable properties from parent private List> STYLEABLES; @Override public List> getControlCssMetaData() { if (STYLEABLES == null) { final List> styleables = new ArrayList<>(Control.getClassCssMetaData()); styleables.addAll(getClassCssMetaData()); styleables.addAll(TextField.getClassCssMetaData()); STYLEABLES = Collections.unmodifiableList(styleables); } return STYLEABLES; } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXToggleButton.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.skins.JFXToggleButtonSkin; import javafx.css.*; import javafx.css.converter.BooleanConverter; import javafx.css.converter.PaintConverter; import javafx.scene.control.Skin; import javafx.scene.control.ToggleButton; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * JFXToggleButton is the material design implementation of a toggle button. * important CSS Selectors: *

* .jfx-toggle-button{ * -fx-toggle-color: color-value; * -fx-untoggle-color: color-value; * -fx-toggle-line-color: color-value; * -fx-untoggle-line-color: color-value; * } *

* To change the rippler color when toggled: *

* .jfx-toggle-button .jfx-rippler{ * -fx-rippler-fill: color-value; * } *

* .jfx-toggle-button:selected .jfx-rippler{ * -fx-rippler-fill: color-value; * } * * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ public class JFXToggleButton extends ToggleButton { /** * {@inheritDoc} */ public JFXToggleButton() { initialize(); } /** * {@inheritDoc} */ @Override protected Skin createDefaultSkin() { return new JFXToggleButtonSkin(this); } private void initialize() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); // it's up for the user to add this behavior // toggleColor.addListener((o, oldVal, newVal) -> { // // update line color in case not set by the user // if(newVal instanceof Color) // toggleLineColor.set(((Color)newVal).desaturate().desaturate().brighter()); // }); } /*************************************************************************** * * * styleable Properties * * * **************************************************************************/ /** * Initialize the style class to 'jfx-toggle-button'. *

* This is the selector class from which CSS can be used to style * this control. */ private static final String DEFAULT_STYLE_CLASS = "jfx-toggle-button"; /** * default color used when the button is toggled */ private final StyleableObjectProperty toggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.TOGGLE_COLOR, JFXToggleButton.this, "toggleColor", Color.valueOf( "#009688")); public Paint getToggleColor() { return toggleColor == null ? Color.valueOf("#009688") : toggleColor.get(); } public StyleableObjectProperty toggleColorProperty() { return this.toggleColor; } public void setToggleColor(Paint color) { this.toggleColor.set(color); } /** * default color used when the button is not toggled */ private StyleableObjectProperty untoggleColor = new SimpleStyleableObjectProperty<>(StyleableProperties.UNTOGGLE_COLOR, JFXToggleButton.this, "unToggleColor", Color.valueOf( "#FAFAFA")); public Paint getUnToggleColor() { return untoggleColor == null ? Color.valueOf("#FAFAFA") : untoggleColor.get(); } public StyleableObjectProperty unToggleColorProperty() { return this.untoggleColor; } public void setUnToggleColor(Paint color) { this.untoggleColor.set(color); } /** * default line color used when the button is toggled */ private final StyleableObjectProperty toggleLineColor = new SimpleStyleableObjectProperty<>( StyleableProperties.TOGGLE_LINE_COLOR, JFXToggleButton.this, "toggleLineColor", Color.valueOf("#77C2BB")); public Paint getToggleLineColor() { return toggleLineColor == null ? Color.valueOf("#77C2BB") : toggleLineColor.get(); } public StyleableObjectProperty toggleLineColorProperty() { return this.toggleLineColor; } public void setToggleLineColor(Paint color) { this.toggleLineColor.set(color); } /** * default line color used when the button is not toggled */ private final StyleableObjectProperty untoggleLineColor = new SimpleStyleableObjectProperty<>( StyleableProperties.UNTOGGLE_LINE_COLOR, JFXToggleButton.this, "unToggleLineColor", Color.valueOf("#999999")); public Paint getUnToggleLineColor() { return untoggleLineColor == null ? Color.valueOf("#999999") : untoggleLineColor.get(); } public StyleableObjectProperty unToggleLineColorProperty() { return this.untoggleLineColor; } public void setUnToggleLineColor(Paint color) { this.untoggleLineColor.set(color); } /** * Default size of the toggle button. */ private final StyleableDoubleProperty size = new SimpleStyleableDoubleProperty( StyleableProperties.SIZE, JFXToggleButton.this, "size", 10.0); public double getSize() { return size.get(); } public StyleableDoubleProperty sizeProperty() { return this.size; } public void setSize(double size) { this.size.set(size); } /** * Disable the visual indicator for focus */ private final StyleableBooleanProperty disableVisualFocus = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_VISUAL_FOCUS, JFXToggleButton.this, "disableVisualFocus", false); public final StyleableBooleanProperty disableVisualFocusProperty() { return this.disableVisualFocus; } public final Boolean isDisableVisualFocus() { return disableVisualFocus != null && this.disableVisualFocusProperty().get(); } public final void setDisableVisualFocus(final Boolean disabled) { this.disableVisualFocusProperty().set(disabled); } /** * disable animation on button action */ private final StyleableBooleanProperty disableAnimation = new SimpleStyleableBooleanProperty(StyleableProperties.DISABLE_ANIMATION, JFXToggleButton.this, "disableAnimation", !AnimationUtils.isAnimationEnabled()); public final StyleableBooleanProperty disableAnimationProperty() { return this.disableAnimation; } public final Boolean isDisableAnimation() { return disableAnimation != null && this.disableAnimationProperty().get(); } public final void setDisableAnimation(final Boolean disabled) { this.disableAnimationProperty().set(disabled); } private static final class StyleableProperties { private static final CssMetaData TOGGLE_COLOR = new CssMetaData<>("-jfx-toggle-color", PaintConverter.getInstance(), Color.valueOf("#009688")) { @Override public boolean isSettable(JFXToggleButton control) { return control.toggleColor == null || !control.toggleColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXToggleButton control) { return control.toggleColorProperty(); } }; private static final CssMetaData UNTOGGLE_COLOR = new CssMetaData<>("-jfx-untoggle-color", PaintConverter.getInstance(), Color.valueOf("#FAFAFA")) { @Override public boolean isSettable(JFXToggleButton control) { return control.untoggleColor == null || !control.untoggleColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXToggleButton control) { return control.unToggleColorProperty(); } }; private static final CssMetaData TOGGLE_LINE_COLOR = new CssMetaData<>("-jfx-toggle-line-color", PaintConverter.getInstance(), Color.valueOf("#77C2BB")) { @Override public boolean isSettable(JFXToggleButton control) { return control.toggleLineColor == null || !control.toggleLineColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXToggleButton control) { return control.toggleLineColorProperty(); } }; private static final CssMetaData UNTOGGLE_LINE_COLOR = new CssMetaData<>("-jfx-untoggle-line-color", PaintConverter.getInstance(), Color.valueOf("#999999")) { @Override public boolean isSettable(JFXToggleButton control) { return control.untoggleLineColor == null || !control.untoggleLineColor.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXToggleButton control) { return control.unToggleLineColorProperty(); } }; private static final CssMetaData SIZE = new CssMetaData<>("-jfx-size", StyleConverter.getSizeConverter(), 10.0) { @Override public boolean isSettable(JFXToggleButton control) { return !control.size.isBound(); } @Override public StyleableProperty getStyleableProperty(JFXToggleButton control) { return control.sizeProperty(); } }; private static final CssMetaData DISABLE_VISUAL_FOCUS = new CssMetaData<>("-jfx-disable-visual-focus", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXToggleButton control) { return control.disableVisualFocus == null || !control.disableVisualFocus.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) { return control.disableVisualFocusProperty(); } }; private static final CssMetaData DISABLE_ANIMATION = new CssMetaData<>("-jfx-disable-animation", BooleanConverter.getInstance(), false) { @Override public boolean isSettable(JFXToggleButton control) { return control.disableAnimation == null || !control.disableAnimation.isBound(); } @Override public StyleableBooleanProperty getStyleableProperty(JFXToggleButton control) { return control.disableAnimationProperty(); } }; private static final List> CHILD_STYLEABLES; static { final List> styleables = new ArrayList<>(ToggleButton.getClassCssMetaData()); Collections.addAll(styleables, SIZE, TOGGLE_COLOR, UNTOGGLE_COLOR, TOGGLE_LINE_COLOR, UNTOGGLE_LINE_COLOR, DISABLE_VISUAL_FOCUS, DISABLE_ANIMATION ); CHILD_STYLEABLES = Collections.unmodifiableList(styleables); } } @Override public List> getControlCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.CHILD_STYLEABLES; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXTreeCell.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import com.jfoenix.utils.JFXNodeUtils; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.TreeCell; import javafx.scene.control.TreeItem; import javafx.scene.layout.*; import javafx.scene.paint.Color; import java.lang.ref.WeakReference; /// JFXTreeCell is simple material design implementation of a tree cell. /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2017-02-15 public class JFXTreeCell extends TreeCell { protected JFXRippler cellRippler = new JFXRippler(this) { @Override protected Node getMask() { Region clip = new Region(); JFXNodeUtils.updateBackground(JFXTreeCell.this.getBackground(), clip); double width = control.getLayoutBounds().getWidth(); double height = control.getLayoutBounds().getHeight(); clip.resize(width, height); return clip; } @Override protected void positionControl(Node control) { // do nothing } }; private HBox hbox; private final StackPane selectedPane = new StackPane(); private final InvalidationListener treeItemGraphicInvalidationListener = observable -> updateDisplay(getItem(), isEmpty()); private final WeakInvalidationListener weakTreeItemGraphicListener = new WeakInvalidationListener( treeItemGraphicInvalidationListener); private WeakReference> treeItemRef; public JFXTreeCell() { selectedPane.getStyleClass().add("selection-bar"); selectedPane.setBackground(new Background(new BackgroundFill(Color.RED, CornerRadii.EMPTY, Insets.EMPTY))); selectedPane.setPrefWidth(3); selectedPane.setMouseTransparent(true); selectedProperty().addListener((o, oldVal, newVal) -> selectedPane.setVisible(newVal ? true : false)); final InvalidationListener treeItemInvalidationListener = observable -> { TreeItem oldTreeItem = treeItemRef == null ? null : treeItemRef.get(); if (oldTreeItem != null) { oldTreeItem.graphicProperty().removeListener(weakTreeItemGraphicListener); } TreeItem newTreeItem = getTreeItem(); if (newTreeItem != null) { newTreeItem.graphicProperty().addListener(weakTreeItemGraphicListener); treeItemRef = new WeakReference<>(newTreeItem); } }; final WeakInvalidationListener weakTreeItemListener = new WeakInvalidationListener(treeItemInvalidationListener); treeItemProperty().addListener(weakTreeItemListener); if (getTreeItem() != null) { getTreeItem().graphicProperty().addListener(weakTreeItemGraphicListener); } } @Override protected void layoutChildren() { super.layoutChildren(); if (!getChildren().contains(selectedPane)) { getChildren().add(0, cellRippler); cellRippler.rippler.clear(); getChildren().add(0, selectedPane); } cellRippler.resizeRelocate(0, 0, getWidth(), getHeight()); cellRippler.releaseRipple(); selectedPane.resizeRelocate(0, 0, selectedPane.prefWidth(-1), getHeight()); selectedPane.setVisible(isSelected()); } private void updateDisplay(T item, boolean empty) { if (item == null || empty) { hbox = null; setText(null); setGraphic(null); } else { TreeItem treeItem = getTreeItem(); if (treeItem != null && treeItem.getGraphic() != null) { if (item instanceof Node) { setText(null); if (hbox == null) { hbox = new HBox(3); } hbox.getChildren().setAll(treeItem.getGraphic(), (Node) item); setGraphic(hbox); } else { hbox = null; setText(item.toString()); setGraphic(treeItem.getGraphic()); } } else { hbox = null; if (item instanceof Node) { setText(null); setGraphic((Node) item); } else { setText(item.toString()); setGraphic(null); } } } } @Override protected void updateItem(T item, boolean empty) { super.updateItem(item, empty); updateDisplay(item, empty); setMouseTransparent(item == null || empty); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/JFXTreeView.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls; import javafx.scene.control.TreeItem; import javafx.scene.control.TreeView; /// JFXTreeView is the material design implementation of a TreeView /// with expand/collapse animation and selection indicator. /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2017-02-15 public class JFXTreeView extends TreeView { private static final String DEFAULT_STYLE_CLASS = "jfx-tree-view"; public JFXTreeView() { init(); } public JFXTreeView(TreeItem root) { super(root); init(); } private void init() { this.setCellFactory((view) -> new JFXTreeCell<>()); this.getStyleClass().add(DEFAULT_STYLE_CLASS); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/behavior/JFXGenericPickerBehavior.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls.behavior; import com.sun.javafx.scene.control.behavior.ComboBoxBaseBehavior; import javafx.scene.control.ComboBoxBase; import javafx.scene.control.PopupControl; /** * @author Shadi Shaheen * @version 2.0 * @since 2017-10-05 */ public class JFXGenericPickerBehavior extends ComboBoxBaseBehavior { public JFXGenericPickerBehavior(ComboBoxBase var1) { super(var1); } public void onAutoHide(PopupControl var1) { if (!var1.isShowing() && this.getNode().isShowing()) { this.getNode().hide(); } if (!this.getNode().isShowing()) { super.onAutoHide(var1); } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/datamodels/treetable/RecursiveTreeObject.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls.datamodels.treetable; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.control.TreeTableColumn; /// data model that is used in JFXTreeTableView, it's used to implement /// the grouping feature. /// /// **Note:** the data object used in JFXTreeTableView **must** extends this class /// /// @param is the concrete object of the Tree table /// @author Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class RecursiveTreeObject { /// grouped children objects private ObservableList children = FXCollections.observableArrayList(); public ObservableList getChildren() { return children; } public void setChildren(ObservableList children) { this.children = children; } /// Whether or not the object is grouped by a specified tree table column ObjectProperty> groupedColumn = new SimpleObjectProperty<>(); public final ObjectProperty> groupedColumnProperty() { return this.groupedColumn; } public final TreeTableColumn getGroupedColumn() { return this.groupedColumnProperty().get(); } public final void setGroupedColumn(final TreeTableColumn groupedColumn) { this.groupedColumnProperty().set(groupedColumn); } /// the value that must be shown when grouped ObjectProperty groupedValue = new SimpleObjectProperty<>(); public final ObjectProperty groupedValueProperty() { return this.groupedValue; } public final Object getGroupedValue() { return this.groupedValueProperty().get(); } public final void setGroupedValue(final Object groupedValue) { this.groupedValueProperty().set(groupedValue); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/controls/events/JFXDialogEvent.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.controls.events; import javafx.event.Event; import javafx.event.EventType; import java.io.Serial; /// JFXDialog events, used exclusively by the following methods: /// /// - [com.jfoenix.controls.JFXDialog#show()] /// - [com.jfoenix.controls.JFXDialog#close()] /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXDialogEvent extends Event { @Serial private static final long serialVersionUID = 1L; /// This event occurs when a JFXDialog is closed, no longer visible to the user /// ( after the exit animation ends ) public static final EventType CLOSED = new EventType<>(Event.ANY, "JFX_DIALOG_CLOSED"); /// This event occurs when a JFXDialog is opened, visible to the user /// ( after the entrance animation ends ) public static final EventType OPENED = new EventType<>(Event.ANY, "JFX_DIALOG_OPENED"); /// Construct a new JFXDialog `Event` with the specified event type /// /// @param eventType the event type public JFXDialogEvent(EventType eventType) { super(eventType); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/effects/JFXDepthManager.java ================================================ /* * Copyright (c) 2016 JFoenix * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.jfoenix.effects; import javafx.scene.Node; import javafx.scene.effect.BlurType; import javafx.scene.effect.DropShadow; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; /** * it will create a shadow effect for a given node and a specified depth level. * depth levels are {0,1,2,3,4,5} * * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ public final class JFXDepthManager { private JFXDepthManager() { throw new AssertionError(); } private static final DropShadow[] depth = new DropShadow[] { new DropShadow(BlurType.GAUSSIAN, Color.TRANSPARENT, 0, 0, 0, 0), new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.12), 4, 0, 0, 0), new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.16), 8, 0, 0, 4), new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.20), 12, 0, 0, 4), new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.24), 16, 0, 0, 4), new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.28), 20, 0, 0, 8) }; /** * this method is used to add shadow effect to the node, * however the shadow is not real ( gets affected with node transformations) *

* use {@link #createMaterialNode(Node, int)} instead to generate a real shadow */ public static void setDepth(Node control, int level) { level = level < 0 ? 0 : level; level = level > 5 ? 5 : level; control.setEffect(new DropShadow(BlurType.GAUSSIAN, depth[level].getColor(), depth[level].getRadius(), depth[level].getSpread(), depth[level].getOffsetX(), depth[level].getOffsetY())); } public static int getLevels() { return depth.length; } public static DropShadow getShadowAt(int level) { return depth[level]; } /** * this method will generate a new container node that prevent * control transformation to be applied to the shadow effect * (which makes it looks as a real shadow) */ public static Node createMaterialNode(Node control, int level) { Node container = new Pane(control) { @Override protected double computeMaxWidth(double height) { return computePrefWidth(height); } @Override protected double computeMaxHeight(double width) { return computePrefHeight(width); } @Override protected double computePrefWidth(double height) { return control.prefWidth(height); } @Override protected double computePrefHeight(double width) { return control.prefHeight(width); } }; container.getStyleClass().add("depth-container"); container.setPickOnBounds(false); level = level < 0 ? 0 : level; level = level > 5 ? 5 : level; container.setEffect(new DropShadow(BlurType.GAUSSIAN, depth[level].getColor(), depth[level].getRadius(), depth[level].getSpread(), depth[level].getOffsetX(), depth[level].getOffsetY())); return container; } public static void pop(Node control) { control.setEffect(new DropShadow(BlurType.GAUSSIAN, Color.rgb(0, 0, 0, 0.26), 5, 0.05, 0, 1)); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXButtonSkin.java ================================================ // // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package com.jfoenix.skins; import com.jfoenix.adapters.skins.ButtonSkin; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXRippler; import com.jfoenix.effects.JFXDepthManager; import com.jfoenix.transitions.CachedTransition; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.animation.Transition; import javafx.beans.binding.Bindings; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.effect.DropShadow; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Shape; import javafx.util.Duration; import org.jackhuang.hmcl.ui.FXUtils; public class JFXButtonSkin extends ButtonSkin { private final StackPane buttonContainer = new StackPane(); private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) { protected Node getMask() { StackPane mask = new StackPane(); mask.shapeProperty().bind(JFXButtonSkin.this.buttonContainer.shapeProperty()); mask.backgroundProperty().bind(Bindings.createObjectBinding(() -> new Background( new BackgroundFill(Color.WHITE, JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getRadii() : JFXButtonSkin.this.defaultRadii, JFXButtonSkin.this.buttonContainer.backgroundProperty().get() != null && !JFXButtonSkin.this.buttonContainer.getBackground().getFills().isEmpty() ? JFXButtonSkin.this.buttonContainer.getBackground().getFills().get(0).getInsets() : Insets.EMPTY)), JFXButtonSkin.this.buttonContainer.backgroundProperty())); mask.resize(JFXButtonSkin.this.buttonContainer.getWidth() - JFXButtonSkin.this.buttonContainer.snappedRightInset() - JFXButtonSkin.this.buttonContainer.snappedLeftInset(), JFXButtonSkin.this.buttonContainer.getHeight() - JFXButtonSkin.this.buttonContainer.snappedBottomInset() - JFXButtonSkin.this.buttonContainer.snappedTopInset()); return mask; } private void initListeners() { this.ripplerPane.setOnMousePressed((event) -> { if (JFXButtonSkin.this.releaseManualRippler != null) { JFXButtonSkin.this.releaseManualRippler.run(); } JFXButtonSkin.this.releaseManualRippler = null; this.createRipple(event.getX(), event.getY()); }); } }; private Transition clickedAnimation; private final CornerRadii defaultRadii = new CornerRadii(3.0); private boolean invalid = true; private Runnable releaseManualRippler = null; public JFXButtonSkin(JFXButton button) { super(button); this.getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> { if (newVal) { this.releaseManualRippler = this.buttonRippler.createManualRipple(); if (this.clickedAnimation != null) { this.clickedAnimation.setRate(1.0); this.clickedAnimation.play(); } } else { if (this.releaseManualRippler != null) { this.releaseManualRippler.run(); } if (this.clickedAnimation != null) { this.clickedAnimation.setRate(-1.0); this.clickedAnimation.play(); } } }); this.buttonContainer.getChildren().add(this.buttonRippler); button.buttonTypeProperty().addListener((o, oldVal, newVal) -> this.updateButtonType(newVal)); button.setOnMousePressed((e) -> { if (this.clickedAnimation != null) { this.clickedAnimation.setRate(1.0F); this.clickedAnimation.play(); } }); button.setOnMouseReleased((e) -> { if (this.clickedAnimation != null) { this.clickedAnimation.setRate(-1.0F); this.clickedAnimation.play(); } }); ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(button); if (focusVisibleProperty == null) { focusVisibleProperty = button.focusedProperty(); } focusVisibleProperty.addListener((o, oldVal, newVal) -> { if (newVal) { if (!this.getSkinnable().isPressed()) { this.buttonRippler.showOverlay(); } } else { this.buttonRippler.hideOverlay(); } }); button.pressedProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.hideOverlay()); button.setPickOnBounds(false); this.buttonContainer.setPickOnBounds(false); this.buttonContainer.shapeProperty().bind(this.getSkinnable().shapeProperty()); this.buttonContainer.borderProperty().bind(this.getSkinnable().borderProperty()); this.buttonContainer.backgroundProperty().bind(Bindings.createObjectBinding(() -> { if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground()) || this.isJavaDefaultClickedBackground(button.getBackground())) { button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null))); } try { return new Background(new BackgroundFill(this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getFill() : Color.TRANSPARENT, this.getSkinnable().getBackground() != null ? this.getSkinnable().getBackground().getFills().get(0).getRadii() : this.defaultRadii, Insets.EMPTY)); } catch (Exception var3) { return this.getSkinnable().getBackground(); } }, this.getSkinnable().backgroundProperty())); button.ripplerFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal)); if (button.getBackground() == null || this.isJavaDefaultBackground(button.getBackground())) { button.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, this.defaultRadii, null))); } this.updateButtonType(button.getButtonType()); this.updateChildren(); } protected void updateChildren() { super.updateChildren(); if (this.buttonContainer != null) { this.getChildren().add(0, this.buttonContainer); } for (int i = 1; i < this.getChildren().size(); ++i) { this.getChildren().get(i).setMouseTransparent(true); } } protected void layoutChildren(double x, double y, double w, double h) { if (this.invalid) { if (((JFXButton) this.getSkinnable()).getRipplerFill() == null) { for (int i = this.getChildren().size() - 1; i >= 1; --i) { if (this.getChildren().get(i) instanceof Shape shape) { this.buttonRippler.setRipplerFill(shape.getFill()); shape.fillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal)); break; } if (this.getChildren().get(i) instanceof Label label) { this.buttonRippler.setRipplerFill(label.getTextFill()); label.textFillProperty().addListener((o, oldVal, newVal) -> this.buttonRippler.setRipplerFill(newVal)); break; } } } else { this.buttonRippler.setRipplerFill(((JFXButton) this.getSkinnable()).getRipplerFill()); } this.invalid = false; } double shift = 1.0F; this.buttonContainer.resizeRelocate(this.getSkinnable().getLayoutBounds().getMinX() - shift, this.getSkinnable().getLayoutBounds().getMinY() - shift, this.getSkinnable().getWidth() + (double) 2.0F * shift, this.getSkinnable().getHeight() + (double) 2.0F * shift); this.layoutLabelInArea(x, y, w, h); } private boolean isJavaDefaultBackground(Background background) { try { String firstFill = background.getFills().get(0).getFill().toString(); return "0xffffffba".equals(firstFill) || "0xffffffbf".equals(firstFill) || "0xffffff12".equals(firstFill) || "0xffffffbd".equals(firstFill); } catch (Exception var3) { return false; } } private boolean isJavaDefaultClickedBackground(Background background) { try { return "0x039ed3ff".equals(background.getFills().get(0).getFill().toString()); } catch (Exception var3) { return false; } } private void updateButtonType(JFXButton.ButtonType type) { switch (type) { case RAISED -> { JFXDepthManager.setDepth(this.buttonContainer, 2); this.clickedAnimation = new ButtonClickTransition(); } case FLAT -> this.buttonContainer.setEffect(null); } } private class ButtonClickTransition extends CachedTransition { public ButtonClickTransition() { super(JFXButtonSkin.this.buttonContainer, new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(2).radiusProperty().get(), Interpolator.EASE_BOTH), new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(2).spreadProperty().get(), Interpolator.EASE_BOTH), new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(2).offsetXProperty().get(), Interpolator.EASE_BOTH), new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(2).offsetYProperty().get(), Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(1000.0F), new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(5).radiusProperty().get(), Interpolator.EASE_BOTH), new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(5).spreadProperty().get(), Interpolator.EASE_BOTH), new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(5).offsetXProperty().get(), Interpolator.EASE_BOTH), new KeyValue(((DropShadow) JFXButtonSkin.this.buttonContainer.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(5).offsetYProperty().get(), Interpolator.EASE_BOTH)))); this.setCycleDuration(Duration.seconds(0.2)); this.setDelay(Duration.seconds(0.0F)); } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXCheckBoxSkin.java ================================================ // // Source code recreated from a .class file by IntelliJ IDEA // (powered by FernFlower decompiler) // package com.jfoenix.skins; import com.jfoenix.controls.JFXCheckBox; import com.jfoenix.controls.JFXRippler; import com.jfoenix.controls.JFXRippler.RipplerMask; import com.jfoenix.controls.JFXRippler.RipplerPos; import com.jfoenix.transitions.CachedTransition; import com.jfoenix.transitions.JFXFillTransition; import com.jfoenix.utils.JFXNodeUtils; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.animation.Transition; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.geometry.HPos; import javafx.geometry.Insets; import javafx.geometry.VPos; import javafx.scene.control.CheckBox; import javafx.scene.control.skin.CheckBoxSkin; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.Border; import javafx.scene.layout.BorderStroke; import javafx.scene.layout.BorderStrokeStyle; import javafx.scene.layout.BorderWidths; import javafx.scene.layout.CornerRadii; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.SVGPath; import javafx.util.Duration; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; public class JFXCheckBoxSkin extends CheckBoxSkin { private final StackPane box = new StackPane(); private final StackPane mark = new StackPane(); private final double lineThick = 2.0; private final double padding = 10.0; private final JFXRippler rippler; private final AnchorPane container = new AnchorPane(); private final double labelOffset = -8.0; private final Transition transition; private boolean invalid = true; private JFXFillTransition select; public JFXCheckBoxSkin(JFXCheckBox control) { super(control); this.box.setMinSize(18.0, 18.0); this.box.setPrefSize(18.0, 18.0); this.box.setMaxSize(18.0, 18.0); this.box.setBackground(new Background(new BackgroundFill(Color.TRANSPARENT, new CornerRadii(2.0), Insets.EMPTY))); this.box.setBorder(new Border(new BorderStroke(Themes.getColorScheme().getOnSurfaceVariant(), BorderStrokeStyle.SOLID, new CornerRadii(2.0), new BorderWidths(this.lineThick)))); StackPane boxContainer = new StackPane(); boxContainer.getChildren().add(this.box); boxContainer.setPadding(new Insets(this.padding)); this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE, RipplerPos.BACK); this.updateRippleColor(); SVGPath shape = new SVGPath(); shape.setContent("M384 690l452-452 60 60-512 512-238-238 60-60z"); this.mark.setShape(shape); this.mark.setMaxSize(15.0, 12.0); this.mark.setStyle("-fx-background-color:-monet-on-primary; -fx-border-color:-monet-on-primary; -fx-border-width:2px;"); this.mark.setVisible(false); this.mark.setScaleX(0.0); this.mark.setScaleY(0.0); boxContainer.getChildren().add(this.mark); this.container.getChildren().add(this.rippler); AnchorPane.setRightAnchor(this.rippler, this.labelOffset); control.selectedProperty().addListener((o, oldVal, newVal) -> { this.updateRippleColor(); this.playSelectAnimation(newVal); }); ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control); if (focusVisibleProperty == null) focusVisibleProperty = control.focusedProperty(); focusVisibleProperty.addListener((o, oldVal, newVal) -> { if (newVal) { if (!this.getSkinnable().isPressed()) { this.rippler.showOverlay(); } } else { this.rippler.hideOverlay(); } }); control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay()); this.updateChildren(); this.registerChangeListener(control.checkedColorProperty(), ignored -> { if (select != null) { select.stop(); } this.createFillTransition(); updateColors(); }); this.registerChangeListener(control.unCheckedColorProperty(), ignored -> updateColors()); this.transition = new CheckBoxTransition(); this.createFillTransition(); } private void updateRippleColor() { var control = (JFXCheckBox) this.getSkinnable(); this.rippler.setRipplerFill(control.isSelected() ? control.getCheckedColor() : control.getUnCheckedColor()); } private void updateColors() { var control = (JFXCheckBox) getSkinnable(); boolean isSelected = control.isSelected(); JFXNodeUtils.updateBackground(box.getBackground(), box, isSelected ? control.getCheckedColor() : Color.TRANSPARENT); rippler.setRipplerFill(isSelected ? control.getCheckedColor() : control.getUnCheckedColor()); final BorderStroke borderStroke = box.getBorder().getStrokes().get(0); box.setBorder(new Border(new BorderStroke( isSelected ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(), borderStroke.getTopStyle(), borderStroke.getRadii(), borderStroke.getWidths()))); } protected void updateChildren() { super.updateChildren(); if (this.container != null) { this.getChildren().remove(1); this.getChildren().add(this.container); } } protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.box.minWidth(-1.0)) + this.labelOffset + 2.0 * this.padding; } protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeY(this.box.prefWidth(-1.0)) + this.labelOffset + 2.0 * this.padding; } protected void layoutChildren(double x, double y, double w, double h) { CheckBox checkBox = this.getSkinnable(); double boxWidth = this.snapSizeX(this.container.prefWidth(-1.0)); double boxHeight = this.snapSizeY(this.container.prefHeight(-1.0)); double computeWidth = Math.min(checkBox.prefWidth(-1.0), checkBox.minWidth(-1.0)) + this.labelOffset + 2.0 * this.padding; double labelWidth = Math.min(computeWidth - boxWidth, w - this.snapSizeX(boxWidth)) + this.labelOffset + 2.0 * this.padding; double labelHeight = Math.min(checkBox.prefHeight(labelWidth), h); double maxHeight = Math.max(boxHeight, labelHeight); double xOffset = computeXOffset(w, labelWidth + boxWidth, checkBox.getAlignment().getHpos()) + x; double yOffset = computeYOffset(h, maxHeight, checkBox.getAlignment().getVpos()) + x; if (this.invalid) { if (this.getSkinnable().isSelected()) { this.playSelectAnimation(true); } this.invalid = false; } this.layoutLabelInArea(xOffset + boxWidth, yOffset, labelWidth, maxHeight, checkBox.getAlignment()); this.container.resize(boxWidth, boxHeight); this.positionInArea(this.container, xOffset, yOffset, boxWidth, maxHeight, 0.0, checkBox.getAlignment().getHpos(), checkBox.getAlignment().getVpos()); } static double computeXOffset(double width, double contentWidth, HPos hpos) { return switch (hpos) { case LEFT -> 0.0; case CENTER -> (width - contentWidth) / 2.0; case RIGHT -> width - contentWidth; }; } static double computeYOffset(double height, double contentHeight, VPos vpos) { return switch (vpos) { case TOP -> 0.0; case CENTER -> (height - contentHeight) / 2.0; case BOTTOM -> height - contentHeight; default -> 0.0; }; } private void playSelectAnimation(Boolean selection) { if (selection == null) { selection = false; } JFXCheckBox control = (JFXCheckBox) this.getSkinnable(); this.transition.setRate(selection ? 1.0 : -1.0); this.select.setRate(selection ? 1.0 : -1.0); this.transition.play(); this.select.play(); this.box.setBorder(new Border(new BorderStroke( selection ? control.getCheckedColor() : Themes.getColorScheme().getOnSurfaceVariant(), BorderStrokeStyle.SOLID, new CornerRadii(2.0), new BorderWidths(this.lineThick)))); } private void createFillTransition() { this.select = new JFXFillTransition(Duration.millis(120.0), this.box, Color.TRANSPARENT, (Color) ((JFXCheckBox) this.getSkinnable()).getCheckedColor()); this.select.setInterpolator(Interpolator.EASE_OUT); } private final class CheckBoxTransition extends CachedTransition { CheckBoxTransition() { super(JFXCheckBoxSkin.this.mark, new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), false, Interpolator.EASE_OUT), new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), (double) 0.5F, Interpolator.EASE_OUT), new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), (double) 0.5F, Interpolator.EASE_OUT)), new KeyFrame(Duration.millis(400.0), new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), true, Interpolator.EASE_OUT), new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), (double) 0.5F, Interpolator.EASE_OUT), new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), (double) 0.5F, Interpolator.EASE_OUT)), new KeyFrame(Duration.millis(1000.0), new KeyValue(JFXCheckBoxSkin.this.mark.visibleProperty(), true, Interpolator.EASE_OUT), new KeyValue(JFXCheckBoxSkin.this.mark.scaleXProperty(), 1, Interpolator.EASE_OUT), new KeyValue(JFXCheckBoxSkin.this.mark.scaleYProperty(), 1, Interpolator.EASE_OUT)) ) ); this.setCycleDuration(Duration.seconds(0.12)); this.setDelay(Duration.seconds(0.05)); } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXColorPalette.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXColorPicker; import com.jfoenix.utils.JFXNodeUtils; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener.Change; import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.event.Event; import javafx.geometry.Bounds; import javafx.geometry.NodeOrientation; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.PopupControl; import javafx.scene.control.Tooltip; import javafx.scene.input.KeyEvent; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.*; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.scene.shape.StrokeType; import java.util.List; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /** * @author Shadi Shaheen FUTURE WORK: this UI will get re-designed to match material design guidlines */ final class JFXColorPalette extends Region { private static final int SQUARE_SIZE = 15; // package protected for testing purposes JFXColorGrid colorPickerGrid; final JFXButton customColorLink = new JFXButton(i18n("color.custom")); JFXCustomColorPickerDialog customColorDialog = null; private final JFXColorPicker colorPicker; private final GridPane customColorGrid = new GridPane(); private final Label customColorLabel = new Label(i18n("color.recent")); private PopupControl popupControl; private ColorSquare focusedSquare; private Color mouseDragColor = null; private boolean dragDetected = false; private final ColorSquare hoverSquare = new ColorSquare(); public JFXColorPalette(final JFXColorPicker colorPicker) { getStyleClass().add("color-palette-region"); this.colorPicker = colorPicker; colorPickerGrid = new JFXColorGrid(); colorPickerGrid.getChildren().get(0).requestFocus(); customColorLabel.setAlignment(Pos.CENTER_LEFT); customColorLink.setPrefWidth(colorPickerGrid.prefWidth(-1)); customColorLink.setAlignment(Pos.CENTER); customColorLink.setFocusTraversable(true); customColorLink.setOnAction(ev -> { if (customColorDialog == null) { customColorDialog = new JFXCustomColorPickerDialog(popupControl); customColorDialog.customColorProperty().addListener((ov, t1, t2) -> { colorPicker.setValue(customColorDialog.customColorProperty().get()); }); customColorDialog.setOnSave(() -> { Color customColor = customColorDialog.customColorProperty().get(); buildCustomColors(); colorPicker.getCustomColors().add(customColor); updateSelection(customColor); Event.fireEvent(colorPicker, new ActionEvent()); colorPicker.hide(); }); } customColorDialog.setCurrentColor(colorPicker.valueProperty().get()); if (popupControl != null) { popupControl.setAutoHide(false); } customColorDialog.show(); customColorDialog.setOnHidden(event -> { if (popupControl != null) { popupControl.setAutoHide(true); } }); }); initNavigation(); customColorGrid.getStyleClass().add("color-picker-grid"); customColorGrid.setVisible(false); buildCustomColors(); colorPicker.getCustomColors().addListener((Change change) -> buildCustomColors()); VBox paletteBox = new VBox(); paletteBox.getStyleClass().add("color-palette"); paletteBox.getChildren().addAll(colorPickerGrid); if (colorPicker.getPreDefinedColors() == null) { paletteBox.getChildren().addAll(customColorLabel, customColorGrid, customColorLink); } hoverSquare.setMouseTransparent(true); hoverSquare.getStyleClass().addAll("hover-square"); setFocusedSquare(null); getChildren().addAll(paletteBox, hoverSquare); } private void setFocusedSquare(ColorSquare square) { hoverSquare.setVisible(square != null); if (square == focusedSquare) { return; } focusedSquare = square; hoverSquare.setVisible(focusedSquare != null); if (focusedSquare == null) { return; } if (!focusedSquare.isFocused()) { focusedSquare.requestFocus(); } hoverSquare.rectangle.setFill(focusedSquare.rectangle.getFill()); Bounds b = square.localToScene(square.getLayoutBounds()); double x = b.getMinX(); double y = b.getMinY(); double xAdjust; double scaleAdjust = hoverSquare.getScaleX() == 1.0 ? 0 : hoverSquare.getWidth() / 4.0; if (colorPicker.getEffectiveNodeOrientation() == NodeOrientation.RIGHT_TO_LEFT) { x = focusedSquare.getLayoutX(); xAdjust = -focusedSquare.getWidth() + scaleAdjust; } else { xAdjust = focusedSquare.getWidth() / 2.0 + scaleAdjust; } hoverSquare.setLayoutX(snapPositionX(x) - xAdjust); hoverSquare.setLayoutY(snapPositionY(y) - focusedSquare.getHeight() / 2.0 + (hoverSquare.getScaleY() == 1.0 ? 0 : focusedSquare.getHeight() / 4.0)); } private void buildCustomColors() { final ObservableList customColors = colorPicker.getCustomColors(); customColorGrid.getChildren().clear(); if (customColors.isEmpty()) { customColorLabel.setVisible(false); customColorLabel.setManaged(false); customColorGrid.setVisible(false); customColorGrid.setManaged(false); return; } else { customColorLabel.setVisible(true); customColorLabel.setManaged(true); customColorGrid.setVisible(true); customColorGrid.setManaged(true); } int customColumnIndex = 0; int customRowIndex = 0; int remainingSquares = customColors.size() % NUM_OF_COLUMNS; int numEmpty = (remainingSquares == 0) ? 0 : NUM_OF_COLUMNS - remainingSquares; for (int i = 0; i < customColors.size(); i++) { Color c = customColors.get(i); ColorSquare square = new ColorSquare(c, i, true); customColorGrid.add(square, customColumnIndex, customRowIndex); customColumnIndex++; if (customColumnIndex == NUM_OF_COLUMNS) { customColumnIndex = 0; customRowIndex++; } } for (int i = 0; i < numEmpty; i++) { ColorSquare emptySquare = new ColorSquare(); customColorGrid.add(emptySquare, customColumnIndex, customRowIndex); customColumnIndex++; } requestLayout(); } private void initNavigation() { setOnKeyPressed(ke -> { switch (ke.getCode()) { case SPACE: case ENTER: // select the focused color if (focusedSquare != null) { focusedSquare.selectColor(ke); } ke.consume(); break; default: // no-op } }); } public void setPopupControl(PopupControl pc) { this.popupControl = pc; } public JFXColorGrid getColorGrid() { return colorPickerGrid; } public boolean isCustomColorDialogShowing() { return customColorDialog != null && customColorDialog.isVisible(); } class ColorSquare extends StackPane { Rectangle rectangle; boolean isEmpty; public ColorSquare() { this(null, -1, false); } public ColorSquare(Color color, int index) { this(color, index, false); } public ColorSquare(Color color, int index, boolean isCustom) { // Add style class to handle selected color square getStyleClass().add("color-square"); if (color != null) { setFocusTraversable(true); focusedProperty().addListener((s, ov, nv) -> setFocusedSquare(nv ? this : null)); addEventHandler(MouseEvent.MOUSE_ENTERED, event -> setFocusedSquare(ColorSquare.this)); addEventHandler(MouseEvent.MOUSE_EXITED, event -> setFocusedSquare(null)); addEventHandler(MouseEvent.MOUSE_RELEASED, event -> { if (!dragDetected && event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 1) { if (!isEmpty) { Color fill = (Color) rectangle.getFill(); colorPicker.setValue(fill); colorPicker.fireEvent(new ActionEvent()); updateSelection(fill); event.consume(); } colorPicker.hide(); } }); } rectangle = new Rectangle(SQUARE_SIZE, SQUARE_SIZE); if (color == null) { rectangle.setFill(Color.WHITE); isEmpty = true; } else { rectangle.setFill(color); } rectangle.setStrokeType(StrokeType.INSIDE); String tooltipStr = JFXNodeUtils.colorToHex(color); Tooltip.install(this, new Tooltip((tooltipStr == null) ? "" : tooltipStr)); rectangle.getStyleClass().add("color-rect"); getChildren().add(rectangle); } public void selectColor(KeyEvent event) { if (rectangle.getFill() != null) { if (rectangle.getFill() instanceof Color) { colorPicker.setValue((Color) rectangle.getFill()); colorPicker.fireEvent(new ActionEvent()); } event.consume(); } colorPicker.hide(); } } // The skin can update selection if colorpicker value changes.. public void updateSelection(Color color) { setFocusedSquare(null); for (ColorSquare c : colorPickerGrid.getSquares()) { if (c.rectangle.getFill().equals(color)) { setFocusedSquare(c); return; } } // check custom colors for (Node n : customColorGrid.getChildren()) { ColorSquare c = (ColorSquare) n; if (c.rectangle.getFill().equals(color)) { setFocusedSquare(c); return; } } } class JFXColorGrid extends GridPane { private final List squares; final int NUM_OF_COLORS; final int NUM_OF_ROWS; public JFXColorGrid() { getStyleClass().add("color-picker-grid"); setId("ColorCustomizerColorGrid"); int columnIndex = 0; int rowIndex = 0; squares = FXCollections.observableArrayList(); double[] limitedColors = colorPicker.getPreDefinedColors(); limitedColors = limitedColors == null ? RAW_VALUES : limitedColors; NUM_OF_COLORS = limitedColors.length / 3; NUM_OF_ROWS = (int) Math.ceil((double) NUM_OF_COLORS / (double) NUM_OF_COLUMNS); final int numColors = limitedColors.length / 3; Color[] colors = new Color[numColors]; for (int i = 0; i < numColors; i++) { colors[i] = new Color(limitedColors[i * 3] / 255, limitedColors[(i * 3) + 1] / 255, limitedColors[(i * 3) + 2] / 255, 1.0); ColorSquare cs = new ColorSquare(colors[i], i); squares.add(cs); } for (ColorSquare square : squares) { add(square, columnIndex, rowIndex); columnIndex++; if (columnIndex == NUM_OF_COLUMNS) { columnIndex = 0; rowIndex++; } } setOnMouseDragged(t -> { if (!dragDetected) { dragDetected = true; mouseDragColor = colorPicker.getValue(); } int xIndex = clamp(0, (int) t.getX() / (SQUARE_SIZE + 1), NUM_OF_COLUMNS - 1); int yIndex = clamp(0, (int) t.getY() / (SQUARE_SIZE + 1), NUM_OF_ROWS - 1); int index = xIndex + yIndex * NUM_OF_COLUMNS; colorPicker.setValue((Color) squares.get(index).rectangle.getFill()); updateSelection(colorPicker.getValue()); }); addEventHandler(MouseEvent.MOUSE_RELEASED, t -> { if (colorPickerGrid.getBoundsInLocal().contains(t.getX(), t.getY())) { updateSelection(colorPicker.getValue()); colorPicker.fireEvent(new ActionEvent()); colorPicker.hide(); } else { // restore color as mouse release happened outside the grid. if (mouseDragColor != null) { colorPicker.setValue(mouseDragColor); updateSelection(mouseDragColor); } } dragDetected = false; }); } public List getSquares() { return squares; } @Override protected double computePrefWidth(double height) { return (SQUARE_SIZE + 1) * NUM_OF_COLUMNS; } @Override protected double computePrefHeight(double width) { return (SQUARE_SIZE + 1) * NUM_OF_ROWS; } } private static final int NUM_OF_COLUMNS = 10; private static final double[] RAW_VALUES = { // WARNING: always make sure the number of colors is a divisable by NUM_OF_COLUMNS 250, 250, 250, // first row 245, 245, 245, 238, 238, 238, 224, 224, 224, 189, 189, 189, 158, 158, 158, 117, 117, 117, 97, 97, 97, 66, 66, 66, 33, 33, 33, // second row 236, 239, 241, 207, 216, 220, 176, 190, 197, 144, 164, 174, 120, 144, 156, 96, 125, 139, 84, 110, 122, 69, 90, 100, 55, 71, 79, 38, 50, 56, // third row 255, 235, 238, 255, 205, 210, 239, 154, 154, 229, 115, 115, 239, 83, 80, 244, 67, 54, 229, 57, 53, 211, 47, 47, 198, 40, 40, 183, 28, 28, // forth row 252, 228, 236, 248, 187, 208, 244, 143, 177, 240, 98, 146, 236, 64, 122, 233, 30, 99, 216, 27, 96, 194, 24, 91, 173, 20, 87, 136, 14, 79, // fifth row 243, 229, 245, 225, 190, 231, 206, 147, 216, 186, 104, 200, 171, 71, 188, 156, 39, 176, 142, 36, 170, 123, 31, 162, 106, 27, 154, 74, 20, 140, // sixth row 237, 231, 246, 209, 196, 233, 179, 157, 219, 149, 117, 205, 126, 87, 194, 103, 58, 183, 94, 53, 177, 81, 45, 168, 69, 39, 160, 49, 27, 146, // seventh row 232, 234, 246, 197, 202, 233, 159, 168, 218, 121, 134, 203, 92, 107, 192, 63, 81, 181, 57, 73, 171, 48, 63, 159, 40, 53, 147, 26, 35, 126, // eigth row 227, 242, 253, 187, 222, 251, 144, 202, 249, 100, 181, 246, 66, 165, 245, 33, 150, 243, 30, 136, 229, 25, 118, 210, 21, 101, 192, 13, 71, 161, // ninth row 225, 245, 254, 179, 229, 252, 129, 212, 250, 79, 195, 247, 41, 182, 246, 3, 169, 244, 3, 155, 229, 2, 136, 209, 2, 119, 189, 1, 87, 155, // tenth row 224, 247, 250, 178, 235, 242, 128, 222, 234, 77, 208, 225, 38, 198, 218, 0, 188, 212, 0, 172, 193, 0, 151, 167, 0, 131, 143, 0, 96, 100, // eleventh row 224, 242, 241, 178, 223, 219, 128, 203, 196, 77, 182, 172, 38, 166, 154, 0, 150, 136, 0, 137, 123, 0, 121, 107, 0, 105, 92, 0, 77, 64, // twelfth row 232, 245, 233, 200, 230, 201, 165, 214, 167, 129, 199, 132, 102, 187, 106, 76, 175, 80, 67, 160, 71, 56, 142, 60, 46, 125, 50, 27, 94, 32, // thirteenth row 241, 248, 233, 220, 237, 200, 197, 225, 165, 174, 213, 129, 156, 204, 101, 139, 195, 74, 124, 179, 66, 104, 159, 56, 85, 139, 47, 51, 105, 30, // fourteenth row 249, 251, 231, 240, 244, 195, 230, 238, 156, 220, 231, 117, 212, 225, 87, 205, 220, 57, 192, 202, 51, 175, 180, 43, 158, 157, 36, 130, 119, 23, // fifteenth row 255, 253, 231, 255, 249, 196, 255, 245, 157, 255, 241, 118, 255, 238, 88, 255, 235, 59, 253, 216, 53, 251, 192, 45, 249, 168, 37, 245, 127, 23, // sixteenth row 255, 248, 225, 255, 236, 179, 255, 224, 130, 255, 213, 79, 255, 202, 40, 255, 193, 7, 255, 179, 0, 255, 160, 0, 255, 143, 0, 255, 111, 0, // seventeenth row 255, 243, 224, 255, 224, 178, 255, 204, 128, 255, 183, 77, 255, 167, 38, 255, 152, 0, 251, 140, 0, 245, 124, 0, 239, 108, 0, 230, 81, 0, // eighteenth row 251, 233, 231, 255, 204, 188, 255, 171, 145, 255, 138, 101, 255, 112, 67, 255, 87, 34, 244, 81, 30, 230, 74, 25, 216, 67, 21, 191, 54, 12, // nineteenth row 239, 235, 233, 215, 204, 200, 188, 170, 164, 161, 136, 127, 141, 110, 99, 121, 85, 72, 109, 76, 65, 93, 64, 55, 78, 52, 46, 62, 39, 35, }; private static int clamp(int min, int value, int max) { if (value < min) { return min; } if (value > max) { return max; } return value; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerSkin.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXClippedPane; import com.jfoenix.controls.JFXColorPicker; import com.jfoenix.controls.JFXRippler; import com.jfoenix.effects.JFXDepthManager; import com.jfoenix.utils.JFXNodeUtils; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.css.*; import javafx.css.converter.BooleanConverter; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.ColorPicker; import javafx.scene.control.Label; import javafx.scene.control.TextField; import javafx.scene.control.skin.ComboBoxPopupControl; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; import javafx.scene.paint.Color; import javafx.scene.shape.Circle; import javafx.util.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * @author Shadi Shaheen */ public final class JFXColorPickerSkin extends JFXGenericPickerSkin { private final Label displayNode; private final JFXClippedPane colorBox; private JFXColorPalette popupContent; StyleableBooleanProperty colorLabelVisible = new SimpleStyleableBooleanProperty(StyleableProperties.COLOR_LABEL_VISIBLE, JFXColorPickerSkin.this, "colorLabelVisible", true); private final ObjectProperty textFill = new SimpleObjectProperty<>(Color.BLACK); private final ObjectProperty colorBoxBackground = new SimpleObjectProperty<>(null); public JFXColorPickerSkin(final ColorPicker colorPicker) { super(colorPicker); // create displayNode displayNode = new Label(""); displayNode.getStyleClass().add("color-label"); displayNode.setMouseTransparent(true); displayNode.textFillProperty().bind(textFill); // label graphic colorBox = new JFXClippedPane(displayNode); colorBox.getStyleClass().add("color-box"); colorBox.setManaged(false); colorBox.backgroundProperty().bind(colorBoxBackground); initColor(); final JFXRippler rippler = new JFXRippler(colorBox, JFXRippler.RipplerMask.FIT); rippler.ripplerFillProperty().bind(textFill); getChildren().setAll(rippler); JFXDepthManager.setDepth(getSkinnable(), 1); getSkinnable().setPickOnBounds(false); colorPicker.focusedProperty().addListener(observable -> { if (colorPicker.isFocused()) { if (!getSkinnable().isPressed()) { rippler.setOverlayVisible(true); } } else { rippler.setOverlayVisible(false); } }); // add listeners registerChangeListener(colorPicker.valueProperty(), obs -> updateColor()); colorLabelVisible.addListener(invalidate -> { if (colorLabelVisible.get()) { displayNode.setText(JFXNodeUtils.colorToHex(getSkinnable().getValue())); } else { displayNode.setText(""); } }); } @Override protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { double width = 100; String displayNodeText = displayNode.getText(); displayNode.setText("#DDDDDD"); width = Math.max(width, super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset)); displayNode.setText(displayNodeText); return width + rightInset + leftInset; } @Override protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { if (colorBox == null) { reflectUpdateDisplayArea(); } return topInset + colorBox.prefHeight(width) + bottomInset; } @Override protected void layoutChildren(double x, double y, double w, double h) { super.layoutChildren(x, y, w, h); double hInsets = snappedLeftInset() + snappedRightInset(); double vInsets = snappedTopInset() + snappedBottomInset(); double width = w + hInsets; double height = h + vInsets; colorBox.resizeRelocate(0, 0, width, height); } @Override protected Node getPopupContent() { if (popupContent == null) { popupContent = new JFXColorPalette((JFXColorPicker) getSkinnable()); } return popupContent; } @Override public void show() { super.show(); final ColorPicker colorPicker = (ColorPicker) getSkinnable(); popupContent.updateSelection(colorPicker.getValue()); } @Override public Node getDisplayNode() { return displayNode; } private void updateColor() { final ColorPicker colorPicker = (ColorPicker) getSkinnable(); Color color = colorPicker.getValue(); Color circleColor = color == null ? Color.WHITE : color; // update picker box color if (((JFXColorPicker) getSkinnable()).isDisableAnimation()) { JFXNodeUtils.updateBackground(colorBox.getBackground(), colorBox, circleColor); } else { Circle colorCircle = new Circle(); colorCircle.setFill(circleColor); colorCircle.setManaged(false); colorCircle.setLayoutX(colorBox.getWidth() / 4); colorCircle.setLayoutY(colorBox.getHeight() / 2); colorBox.getChildren().add(colorCircle); Timeline animateColor = new Timeline(new KeyFrame(Duration.millis(240), new KeyValue(colorCircle.radiusProperty(), 200, Interpolator.EASE_BOTH))); animateColor.setOnFinished((finish) -> { colorBoxBackground.set(new Background(new BackgroundFill(colorCircle.getFill(), new CornerRadii(3), Insets.EMPTY))); colorBox.getChildren().remove(colorCircle); }); animateColor.play(); } // update label color textFill.set(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf( "rgba(255, 255, 255, 0.87)") : Color.valueOf("rgba(0, 0, 0, 0.87)")); if (colorLabelVisible.get()) { displayNode.setText(JFXNodeUtils.colorToHex(circleColor)); } else { displayNode.setText(""); } } private void initColor() { final ColorPicker colorPicker = (ColorPicker) getSkinnable(); Color color = colorPicker.getValue(); Color circleColor = color == null ? Color.WHITE : color; // update picker box color colorBoxBackground.set(new Background(new BackgroundFill(circleColor, new CornerRadii(3), Insets.EMPTY))); // update label color textFill.set(circleColor.grayscale().getRed() < 0.5 ? Color.valueOf( "rgba(255, 255, 255, 0.87)") : Color.valueOf("rgba(0, 0, 0, 0.87)")); if (colorLabelVisible.get()) { displayNode.setText(JFXNodeUtils.colorToHex(circleColor)); } else { displayNode.setText(""); } } /*************************************************************************** * * * Stylesheet Handling * * * **************************************************************************/ private static final class StyleableProperties { private static final CssMetaData COLOR_LABEL_VISIBLE = new CssMetaData("-fx-color-label-visible", BooleanConverter.getInstance(), Boolean.TRUE) { @Override public boolean isSettable(ColorPicker n) { final JFXColorPickerSkin skin = (JFXColorPickerSkin) n.getSkin(); return skin.colorLabelVisible == null || !skin.colorLabelVisible.isBound(); } @Override public StyleableProperty getStyleableProperty(ColorPicker n) { final JFXColorPickerSkin skin = (JFXColorPickerSkin) n.getSkin(); return skin.colorLabelVisible; } }; private static final List> STYLEABLES; static { final List> styleables = new ArrayList<>(ComboBoxPopupControl.getClassCssMetaData()); styleables.add(COLOR_LABEL_VISIBLE); STYLEABLES = Collections.unmodifiableList(styleables); } } public static List> getClassCssMetaData() { return StyleableProperties.STYLEABLES; } @Override public List> getCssMetaData() { return getClassCssMetaData(); } protected TextField getEditor() { return null; } protected javafx.util.StringConverter getConverter() { return null; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXColorPickerUI.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.effects.JFXDepthManager; import com.jfoenix.transitions.CachedTransition; import javafx.animation.Animation.Status; import javafx.animation.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.effect.ColorAdjust; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.image.PixelWriter; import javafx.scene.image.WritableImage; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.scene.shape.Circle; import javafx.scene.shape.Path; import javafx.util.Duration; /** * @author Shadi Shaheen & Bassel El Mabsout this UI allows the user to pick a color using HSL color system */ final class JFXColorPickerUI extends Pane { private CachedTransition selectorTransition; private int pickerSize = 400; // sl circle selector size private final int selectorSize = 20; private final double centerX; private final double centerY; private final double huesRadius; private final double slRadius; private double currentHue = 0; private final ImageView huesCircleView; private final ImageView slCircleView; private final Pane colorSelector; private final Pane selector; private CurveTransition colorsTransition; public JFXColorPickerUI(int pickerSize) { JFXDepthManager.setDepth(this, 1); this.pickerSize = pickerSize; this.centerX = (double) pickerSize / 2; this.centerY = (double) pickerSize / 2; final double pickerRadius = (double) pickerSize / 2; this.huesRadius = pickerRadius * 0.9; final double huesSmallR = pickerRadius * 0.8; final double huesLargeR = pickerRadius; this.slRadius = pickerRadius * 0.7; // Create Hues Circle huesCircleView = new ImageView(getHuesCircle(pickerSize, pickerSize)); // clip to smooth the edges Circle outterCircle = new Circle(centerX, centerY, huesLargeR - 2); Circle innterCircle = new Circle(centerX, centerY, huesSmallR + 2); huesCircleView.setClip(Path.subtract(outterCircle, innterCircle)); this.getChildren().add(huesCircleView); // create Hues Circle Selector Circle r1 = new Circle(pickerRadius - huesSmallR); Circle r2 = new Circle(pickerRadius - huesRadius); colorSelector = new Pane(); colorSelector.setStyle("-fx-border-color:#424242; -fx-border-width:1px; -fx-background-color:rgba(255, 255, 255, 0.87);"); colorSelector.setPrefSize(pickerRadius - huesSmallR, pickerRadius - huesSmallR); colorSelector.setShape(Path.subtract(r1, r2)); colorSelector.setCache(true); colorSelector.setMouseTransparent(true); colorSelector.setPickOnBounds(false); this.getChildren().add(colorSelector); // add Hues Selection Listeners huesCircleView.addEventHandler(MouseEvent.MOUSE_DRAGGED, (event) -> { if (colorsTransition != null) { colorsTransition.stop(); } double dx = event.getX() - centerX; double dy = event.getY() - centerY; double theta = Math.atan2(dy, dx); double x = centerX + huesRadius * Math.cos(theta); double y = centerY + huesRadius * Math.sin(theta); colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(dy, dx))); colorSelector.setTranslateX(x - colorSelector.getPrefWidth() / 2); colorSelector.setTranslateY(y - colorSelector.getPrefHeight() / 2); }); huesCircleView.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> { double dx = event.getX() - centerX; double dy = event.getY() - centerY; double theta = Math.atan2(dy, dx); double x = centerX + huesRadius * Math.cos(theta); double y = centerY + huesRadius * Math.sin(theta); colorsTransition = new CurveTransition(new Point2D(colorSelector.getTranslateX() + colorSelector.getPrefWidth() / 2, colorSelector.getTranslateY() + colorSelector.getPrefHeight() / 2), new Point2D(x, y)); colorsTransition.play(); }); colorSelector.translateXProperty() .addListener((o, oldVal, newVal) -> updateHSLCircleColor((int) (newVal.intValue() + colorSelector.getPrefWidth() / 2), (int) (colorSelector.getTranslateY() + colorSelector .getPrefHeight() / 2))); colorSelector.translateYProperty() .addListener((o, oldVal, newVal) -> updateHSLCircleColor((int) (colorSelector.getTranslateX() + colorSelector .getPrefWidth() / 2), (int) (newVal.intValue() + colorSelector.getPrefHeight() / 2))); // Create SL Circle slCircleView = new ImageView(getSLCricle(pickerSize, pickerSize)); slCircleView.setClip(new Circle(centerX, centerY, slRadius - 2)); slCircleView.setPickOnBounds(false); this.getChildren().add(slCircleView); // create SL Circle Selector selector = new Pane(); Circle c1 = new Circle(selectorSize / 2); Circle c2 = new Circle((selectorSize / 2) * 0.5); selector.setShape(Path.subtract(c1, c2)); selector.setStyle( "-fx-border-color:#424242; -fx-border-width:1px;-fx-background-color:rgba(255, 255, 255, 0.87);"); selector.setPrefSize(selectorSize, selectorSize); selector.setMinSize(selectorSize, selectorSize); selector.setMaxSize(selectorSize, selectorSize); selector.setCache(true); selector.setMouseTransparent(true); this.getChildren().add(selector); // add SL selection Listeners slCircleView.addEventHandler(MouseEvent.MOUSE_DRAGGED, (event) -> { if (selectorTransition != null) { selectorTransition.stop(); } if (Math.pow(event.getX() - centerX, 2) + Math.pow(event.getY() - centerY, 2) < Math.pow(slRadius - 2, 2)) { selector.setTranslateX(event.getX() - selector.getPrefWidth() / 2); selector.setTranslateY(event.getY() - selector.getPrefHeight() / 2); } else { double dx = event.getX() - centerX; double dy = event.getY() - centerY; double theta = Math.atan2(dy, dx); double x = centerX + (slRadius - 2) * Math.cos(theta); double y = centerY + (slRadius - 2) * Math.sin(theta); selector.setTranslateX(x - selector.getPrefWidth() / 2); selector.setTranslateY(y - selector.getPrefHeight() / 2); } }); slCircleView.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> { selectorTransition = new CachedTransition(selector, new Timeline(new KeyFrame(Duration.millis(1000), new KeyValue(selector.translateXProperty(), event.getX() - selector.getPrefWidth() / 2, Interpolator.EASE_BOTH), new KeyValue(selector.translateYProperty(), event.getY() - selector.getPrefHeight() / 2, Interpolator.EASE_BOTH)))) { { setCycleDuration(Duration.millis(160)); setDelay(Duration.seconds(0)); } }; selectorTransition.play(); }); // add slCircleView listener selector.translateXProperty() .addListener((o, oldVal, newVal) -> setColorAtLocation(newVal.intValue() + selectorSize / 2, (int) selector.getTranslateY() + selectorSize / 2)); selector.translateYProperty() .addListener((o, oldVal, newVal) -> setColorAtLocation((int) selector.getTranslateX() + selectorSize / 2, newVal.intValue() + selectorSize / 2)); // initial color selection double dx = 20 - centerX; double dy = 20 - centerY; double theta = Math.atan2(dy, dx); double x = centerX + huesRadius * Math.cos(theta); double y = centerY + huesRadius * Math.sin(theta); colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(dy, dx))); colorSelector.setTranslateX(x - colorSelector.getPrefWidth() / 2); colorSelector.setTranslateY(y - colorSelector.getPrefHeight() / 2); selector.setTranslateX(centerX - selector.getPrefWidth() / 2); selector.setTranslateY(centerY - selector.getPrefHeight() / 2); } /** * List of Color Nodes that needs to be updated when picking a color */ private final ObservableList colorNodes = FXCollections.observableArrayList(); public void addColorSelectionNode(Node... nodes) { colorNodes.addAll(nodes); } public void removeColorSelectionNode(Node... nodes) { colorNodes.removeAll(nodes); } private void updateHSLCircleColor(int x, int y) { // transform color to HSL space Color color = huesCircleView.getImage().getPixelReader().getColor(x, y); double max = Math.max(color.getRed(), Math.max(color.getGreen(), color.getBlue())); double min = Math.min(color.getRed(), Math.min(color.getGreen(), color.getBlue())); double hue = 0; if (max != min) { double d = max - min; if (max == color.getRed()) { hue = (color.getGreen() - color.getBlue()) / d + (color.getGreen() < color.getBlue() ? 6 : 0); } else if (max == color.getGreen()) { hue = (color.getBlue() - color.getRed()) / d + 2; } else if (max == color.getBlue()) { hue = (color.getRed() - color.getGreen()) / d + 4; } hue /= 6; } currentHue = map(hue, 0, 1, 0, 255); // refresh the HSL circle refreshHSLCircle(); } private void refreshHSLCircle() { ColorAdjust colorAdjust = new ColorAdjust(); colorAdjust.setHue(map(currentHue + (currentHue < 127.5 ? 1 : -1) * 127.5, 0, 255, -1, 1)); slCircleView.setEffect(colorAdjust); setColorAtLocation((int) selector.getTranslateX() + selectorSize / 2, (int) selector.getTranslateY() + selectorSize / 2); } /** * this method is used to move selectors to a certain color */ private boolean allowColorChange = true; private ParallelTransition pTrans; public void moveToColor(Color color) { allowColorChange = false; double max = Math.max(color.getRed(), Math.max(color.getGreen(), color.getBlue())), min = Math.min(color.getRed(), Math.min(color.getGreen(), color.getBlue())); double hue = 0; double l = (max + min) / 2; double s = 0; if (max == min) { hue = s = 0; // achromatic } else { double d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); if (max == color.getRed()) { hue = (color.getGreen() - color.getBlue()) / d + (color.getGreen() < color.getBlue() ? 6 : 0); } else if (max == color.getGreen()) { hue = (color.getBlue() - color.getRed()) / d + 2; } else if (max == color.getBlue()) { hue = (color.getRed() - color.getGreen()) / d + 4; } hue /= 6; } currentHue = map(hue, 0, 1, 0, 255); // Animate Hue double theta = map(currentHue, 0, 255, -Math.PI, Math.PI); double x = centerX + huesRadius * Math.cos(theta); double y = centerY + huesRadius * Math.sin(theta); colorsTransition = new CurveTransition( new Point2D( colorSelector.getTranslateX() + colorSelector.getPrefWidth() / 2, colorSelector.getTranslateY() + colorSelector.getPrefHeight() / 2 ), new Point2D(x, y)); // Animate SL s = map(s, 0, 1, 0, 255); l = map(l, 0, 1, 0, 255); Point2D point = getPointFromSL((int) s, (int) l, slRadius); double pX = centerX - point.getX(); double pY = centerY - point.getY(); double endPointX; double endPointY; if (Math.pow(pX - centerX, 2) + Math.pow(pY - centerY, 2) < Math.pow(slRadius - 2, 2)) { endPointX = pX - selector.getPrefWidth() / 2; endPointY = pY - selector.getPrefHeight() / 2; } else { double dx = pX - centerX; double dy = pY - centerY; theta = Math.atan2(dy, dx); x = centerX + (slRadius - 2) * Math.cos(theta); y = centerY + (slRadius - 2) * Math.sin(theta); endPointX = x - selector.getPrefWidth() / 2; endPointY = y - selector.getPrefHeight() / 2; } selectorTransition = new CachedTransition(selector, new Timeline(new KeyFrame(Duration.millis(1000), new KeyValue(selector.translateXProperty(), endPointX, Interpolator.EASE_BOTH), new KeyValue(selector.translateYProperty(), endPointY, Interpolator.EASE_BOTH)))) { { setCycleDuration(Duration.millis(160)); setDelay(Duration.seconds(0)); } }; if (pTrans != null) { pTrans.stop(); } pTrans = new ParallelTransition(colorsTransition, selectorTransition); pTrans.setOnFinished((finish) -> { if (pTrans.getStatus() == Status.STOPPED) { allowColorChange = true; } }); pTrans.play(); refreshHSLCircle(); } private void setColorAtLocation(int x, int y) { if (allowColorChange) { Color color = getColorAtLocation(x, y); String colorString = "rgb(" + color.getRed() * 255 + "," + color.getGreen() * 255 + "," + color.getBlue() * 255 + ");"; for (Node node : colorNodes) node.setStyle("-fx-background-color:" + colorString + "; -fx-fill:" + colorString + ";"); } } private Color getColorAtLocation(double x, double y) { double dy = x - centerX; double dx = y - centerY; return getColor(dx, dy); } private Image getHuesCircle(int width, int height) { WritableImage raster = new WritableImage(width, height); PixelWriter pixelWriter = raster.getPixelWriter(); Point2D center = new Point2D((double) width / 2, (double) height / 2); double rsmall = 0.8 * width / 2; double rbig = (double) width / 2; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { double dx = x - center.getX(); double dy = y - center.getY(); double distance = Math.sqrt((dx * dx) + (dy * dy)); double o = Math.atan2(dy, dx); if (distance > rsmall && distance < rbig) { double H = map(o, -Math.PI, Math.PI, 0, 255); double S = 255; double L = 152; pixelWriter.setColor(x, y, HSL2RGB(H, S, L)); } } } return raster; } private Image getSLCricle(int width, int height) { WritableImage raster = new WritableImage(width, height); PixelWriter pixelWriter = raster.getPixelWriter(); Point2D center = new Point2D((double) width / 2, (double) height / 2); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { double dy = x - center.getX(); double dx = y - center.getY(); pixelWriter.setColor(x, y, getColor(dx, dy)); } } return raster; } private double clamp(double from, double small, double big) { return Math.min(Math.max(from, small), big); } private Color getColor(double dx, double dy) { double distance = Math.sqrt((dx * dx) + (dy * dy)); double rverysmall = 0.65 * ((double) pickerSize / 2); Color pixelColor = Color.BLUE; if (distance <= rverysmall * 1.1) { double angle = -Math.PI / 2.; double angle1 = angle + 2 * Math.PI / 3.; double angle2 = angle1 + 2 * Math.PI / 3.; double x1 = rverysmall * Math.sin(angle1); double y1 = rverysmall * Math.cos(angle1); double x2 = rverysmall * Math.sin(angle2); double y2 = rverysmall * Math.cos(angle2); dx += 0.01; double[] circle = circleFrom3Points(new Point2D(x1, y1), new Point2D(x2, y2), new Point2D(dx, dy)); double xArc = circle[0]; double yArc = 0; double arcR = circle[2]; double Arco = Math.atan2(dx - xArc, dy - yArc); double Arco1 = Math.atan2(x1 - xArc, y1 - yArc); double Arco2 = Math.atan2(x2 - xArc, y2 - yArc); double finalX = xArc > 0 ? xArc - arcR : xArc + arcR; double saturation = map(finalX, -rverysmall, rverysmall, 255, 0); double lightness = 255; double diffAngle = Arco2 - Arco1; double diffArco = Arco - Arco1; if (dx < x1) { diffAngle = diffAngle < 0 ? 2 * Math.PI + diffAngle : diffAngle; diffAngle = Math.abs(2 * Math.PI - diffAngle); diffArco = diffArco < 0 ? 2 * Math.PI + diffArco : diffArco; diffArco = Math.abs(2 * Math.PI - diffArco); } lightness = map(diffArco, 0, diffAngle, 0, 255); if (distance > rverysmall) { saturation = 255 - saturation; if (lightness < 0 && dy < 0) { lightness = 255; } } lightness = clamp(lightness, 0, 255); if ((saturation < 10 && dx < x1) || (saturation > 240 && dx > x1)) { saturation = 255 - saturation; } saturation = clamp(saturation, 0, 255); pixelColor = HSL2RGB(currentHue, saturation, lightness); } return pixelColor; } /*************************************************************************** * * * Hues Animation * * * **************************************************************************/ private final class CurveTransition extends Transition { Point2D from; double fromTheta; double toTheta; public CurveTransition(Point2D from, Point2D to) { this.from = from; double fromDx = from.getX() - centerX; double fromDy = from.getY() - centerY; fromTheta = Math.atan2(fromDy, fromDx); double toDx = to.getX() - centerX; double toDy = to.getY() - centerY; toTheta = Math.atan2(toDy, toDx); setInterpolator(Interpolator.EASE_BOTH); setDelay(Duration.millis(0)); setCycleDuration(Duration.millis(240)); } @Override protected void interpolate(double frac) { double dif = Math.min(Math.abs(toTheta - fromTheta), 2 * Math.PI - Math.abs(toTheta - fromTheta)); if (dif == 2 * Math.PI - Math.abs(toTheta - fromTheta)) { int dir = -1; if (toTheta < fromTheta) { dir = 1; } dif = dir * dif; } else { dif = toTheta - fromTheta; } Point2D newP = rotate(from, new Point2D(centerX, centerY), frac * dif); colorSelector.setRotate(90 + Math.toDegrees(Math.atan2(newP.getY() - centerY, newP.getX() - centerX))); colorSelector.setTranslateX(newP.getX() - colorSelector.getPrefWidth() / 2); colorSelector.setTranslateY(newP.getY() - colorSelector.getPrefHeight() / 2); } } /*************************************************************************** * * * Util methods * * * **************************************************************************/ private double map(double val, double min1, double max1, double min2, double max2) { return min2 + (max2 - min2) * ((val - min1) / (max1 - min1)); } private Color HSL2RGB(double hue, double sat, double lum) { hue = map(hue, 0, 255, 0, 359); sat = map(sat, 0, 255, 0, 1); lum = map(lum, 0, 255, 0, 1); double v; double red, green, blue; double m; double sv; int sextant; double fract, vsf, mid1, mid2; red = lum; // default to gray green = lum; blue = lum; v = (lum <= 0.5) ? (lum * (1.0 + sat)) : (lum + sat - lum * sat); m = lum + lum - v; sv = (v - m) / v; hue /= 60.0; //get into range 0..6 sextant = (int) Math.floor(hue); // int32 rounds up or down. fract = hue - sextant; vsf = v * sv * fract; mid1 = m + vsf; mid2 = v - vsf; if (v > 0) { switch (sextant) { case 0: red = v; green = mid1; blue = m; break; case 1: red = mid2; green = v; blue = m; break; case 2: red = m; green = v; blue = mid1; break; case 3: red = m; green = mid2; blue = v; break; case 4: red = mid1; green = m; blue = v; break; case 5: red = v; green = m; blue = mid2; break; } } return new Color(red, green, blue, 1); } private double[] circleFrom3Points(Point2D a, Point2D b, Point2D c) { double ax, ay, bx, by, cx, cy, x1, y11, dx1, dy1, x2, y2, dx2, dy2, ox, oy, dx, dy, radius; // Variables Used and to Declared ax = a.getX(); ay = a.getY(); //first Point X and Y bx = b.getX(); by = b.getY(); // Second Point X and Y cx = c.getX(); cy = c.getY(); // Third Point X and Y ////****************Following are Basic Procedure**********************/// x1 = (bx + ax) / 2; y11 = (by + ay) / 2; dy1 = bx - ax; dx1 = -(by - ay); x2 = (cx + bx) / 2; y2 = (cy + by) / 2; dy2 = cx - bx; dx2 = -(cy - by); ox = (y11 * dx1 * dx2 + x2 * dx1 * dy2 - x1 * dy1 * dx2 - y2 * dx1 * dx2) / (dx1 * dy2 - dy1 * dx2); oy = (ox - x1) * dy1 / dx1 + y11; dx = ox - ax; dy = oy - ay; radius = Math.sqrt(dx * dx + dy * dy); return new double[]{ox, oy, radius}; } private Point2D getPointFromSL(int saturation, int lightness, double radius) { double dy = map(saturation, 0, 255, -radius, radius); double angle = 0.; double angle1 = angle + 2 * Math.PI / 3.; double angle2 = angle1 + 2 * Math.PI / 3.; double x1 = radius * Math.sin(angle1); double y1 = radius * Math.cos(angle1); double x2 = radius * Math.sin(angle2); double y2 = radius * Math.cos(angle2); double dx = 0; double[] circle = circleFrom3Points(new Point2D(x1, y1), new Point2D(dx, dy), new Point2D(x2, y2)); double xArc = circle[0]; double yArc = circle[1]; double arcR = circle[2]; double Arco1 = Math.atan2(x1 - xArc, y1 - yArc); double Arco2 = Math.atan2(x2 - xArc, y2 - yArc); double ArcoFinal = map(lightness, 0, 255, Arco2, Arco1); double finalX = xArc + arcR * Math.sin(ArcoFinal); double finalY = yArc + arcR * Math.cos(ArcoFinal); if (dy < y1) { ArcoFinal = map(lightness, 0, 255, Arco1, Arco2 + 2 * Math.PI); finalX = -xArc - arcR * Math.sin(ArcoFinal); finalY = yArc + arcR * Math.cos(ArcoFinal); } return new Point2D(finalX, finalY); } private Point2D rotate(Point2D a, Point2D center, double angle) { double resultX = center.getX() + (a.getX() - center.getX()) * Math.cos(angle) - (a.getY() - center.getY()) * Math .sin(angle); double resultY = center.getY() + (a.getX() - center.getX()) * Math.sin(angle) + (a.getY() - center.getY()) * Math .cos(angle); return new Point2D(resultX, resultY); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPicker.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.effects.JFXDepthManager; import com.jfoenix.transitions.CachedTransition; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.beans.binding.Bindings; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.effect.DropShadow; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.scene.paint.Color; import javafx.scene.shape.*; import javafx.scene.transform.Rotate; import javafx.util.Duration; import java.util.ArrayList; import static javafx.animation.Interpolator.EASE_BOTH; /// @author Shadi Shaheen final class JFXCustomColorPicker extends Pane { ObjectProperty selectedPath = new SimpleObjectProperty<>(); private MoveTo startPoint; private CubicCurveTo curve0To; private CubicCurveTo outerCircleCurveTo; private CubicCurveTo curve1To; private CubicCurveTo innerCircleCurveTo; private final ArrayList curves = new ArrayList<>(); private final double distance = 200; private final double centerX = distance; private final double centerY = distance; private final double radius = 110; private static final int shapesNumber = 13; private final ArrayList shapes = new ArrayList<>(); private CachedTransition showAnimation; private final JFXColorPickerUI hslColorPicker; public JFXCustomColorPicker() { this.setPickOnBounds(false); this.setMinSize(distance * 2, distance * 2); final DoubleProperty rotationAngle = new SimpleDoubleProperty(2.1); // draw recent colors shape using cubic curves init(rotationAngle, centerX + 53, centerY + 162); hslColorPicker = new JFXColorPickerUI((int) distance); hslColorPicker.setLayoutX(centerX - distance / 2); hslColorPicker.setLayoutY(centerY - distance / 2); this.getChildren().add(hslColorPicker); // add recent colors shapes final int shapesStartIndex = this.getChildren().size(); final int shapesEndIndex = shapesStartIndex + shapesNumber; for (int i = 0; i < shapesNumber; i++) { final double angle = 2 * i * Math.PI / shapesNumber; final RecentColorPath path = new RecentColorPath(startPoint, curve0To, outerCircleCurveTo, curve1To, innerCircleCurveTo); shapes.add(path); path.setPickOnBounds(false); final Rotate rotate = new Rotate(Math.toDegrees(angle), centerX, centerY); path.getTransforms().add(rotate); this.getChildren().add(shapesStartIndex, path); path.setFill(Color.valueOf(getDefaultColor(i))); path.setFocusTraversable(true); path.addEventHandler(MouseEvent.MOUSE_CLICKED, (event) -> { path.requestFocus(); selectedPath.set(path); }); } // add selection listeners selectedPath.addListener((o, oldVal, newVal) -> { if (oldVal != null) { hslColorPicker.removeColorSelectionNode(oldVal); oldVal.playTransition(-1); } // re-arrange children while (this.getChildren().indexOf(newVal) != shapesEndIndex - 1) { final Node temp = this.getChildren().get(shapesEndIndex - 1); this.getChildren().remove(shapesEndIndex - 1); this.getChildren().add(shapesStartIndex, temp); } // update path fill according to the color picker newVal.setStroke(Color.rgb(255, 255, 255, 0.87)); newVal.playTransition(1); hslColorPicker.moveToColor((Color) newVal.getFill()); hslColorPicker.addColorSelectionNode(newVal); }); // init selection selectedPath.set((RecentColorPath) this.getChildren().get(shapesStartIndex)); } public int getShapesNumber() { return shapesNumber; } public int getSelectedIndex() { if (selectedPath.get() != null) { return shapes.indexOf(selectedPath.get()); } return -1; } public void setColor(final Color color) { shapes.get(getSelectedIndex()).setFill(color); hslColorPicker.moveToColor(color); } public Color getColor(final int index) { if (index >= 0 && index < shapes.size()) { return (Color) shapes.get(index).getFill(); } else { return Color.WHITE; } } public void preAnimate() { final CubicCurve firstCurve = curves.get(0); final double x = firstCurve.getStartX(); final double y = firstCurve.getStartY(); firstCurve.setStartX(centerX); firstCurve.setStartY(centerY); final CubicCurve secondCurve = curves.get(1); final double x1 = secondCurve.getStartX(); final double y1 = secondCurve.getStartY(); secondCurve.setStartX(centerX); secondCurve.setStartY(centerY); final double cx1 = firstCurve.getControlX1(); final double cy1 = firstCurve.getControlY1(); firstCurve.setControlX1(centerX + radius); firstCurve.setControlY1(centerY + radius / 2); final KeyFrame keyFrame = new KeyFrame(Duration.millis(1000), new KeyValue(firstCurve.startXProperty(), x, EASE_BOTH), new KeyValue(firstCurve.startYProperty(), y, EASE_BOTH), new KeyValue(secondCurve.startXProperty(), x1, EASE_BOTH), new KeyValue(secondCurve.startYProperty(), y1, EASE_BOTH), new KeyValue(firstCurve.controlX1Property(), cx1, EASE_BOTH), new KeyValue(firstCurve.controlY1Property(), cy1, EASE_BOTH) ); final Timeline timeline = new Timeline(keyFrame); showAnimation = new CachedTransition(this, timeline) { { setCycleDuration(Duration.millis(240)); setDelay(Duration.millis(0)); } }; } public void animate() { showAnimation.play(); } private void init(final DoubleProperty rotationAngle, final double initControlX1, final double initControlY1) { final Circle innerCircle = new Circle(centerX, centerY, radius, Color.TRANSPARENT); final Circle outerCircle = new Circle(centerX, centerY, radius * 2, Color.web("blue", 0.5)); // Create a composite shape of 4 cubic curves // create 2 cubic curves of the shape createQuadraticCurve(rotationAngle, initControlX1, initControlY1); // inner circle curve final CubicCurve innerCircleCurve = new CubicCurve(); innerCircleCurve.startXProperty().bind(curves.get(0).startXProperty()); innerCircleCurve.startYProperty().bind(curves.get(0).startYProperty()); innerCircleCurve.endXProperty().bind(curves.get(1).startXProperty()); innerCircleCurve.endYProperty().bind(curves.get(1).startYProperty()); curves.get(0).startXProperty().addListener((o, oldVal, newVal) -> { final Point2D controlPoint = makeControlPoint(newVal.doubleValue(), curves.get(0).getStartY(), innerCircle, shapesNumber, -1); innerCircleCurve.setControlX1(controlPoint.getX()); innerCircleCurve.setControlY1(controlPoint.getY()); }); curves.get(0).startYProperty().addListener((o, oldVal, newVal) -> { final Point2D controlPoint = makeControlPoint(curves.get(0).getStartX(), newVal.doubleValue(), innerCircle, shapesNumber, -1); innerCircleCurve.setControlX1(controlPoint.getX()); innerCircleCurve.setControlY1(controlPoint.getY()); }); curves.get(1).startXProperty().addListener((o, oldVal, newVal) -> { final Point2D controlPoint = makeControlPoint(newVal.doubleValue(), curves.get(1).getStartY(), innerCircle, shapesNumber, 1); innerCircleCurve.setControlX2(controlPoint.getX()); innerCircleCurve.setControlY2(controlPoint.getY()); }); curves.get(1).startYProperty().addListener((o, oldVal, newVal) -> { final Point2D controlPoint = makeControlPoint(curves.get(1).getStartX(), newVal.doubleValue(), innerCircle, shapesNumber, 1); innerCircleCurve.setControlX2(controlPoint.getX()); innerCircleCurve.setControlY2(controlPoint.getY()); }); Point2D controlPoint = makeControlPoint(curves.get(0).getStartX(), curves.get(0).getStartY(), innerCircle, shapesNumber, -1); innerCircleCurve.setControlX1(controlPoint.getX()); innerCircleCurve.setControlY1(controlPoint.getY()); controlPoint = makeControlPoint(curves.get(1).getStartX(), curves.get(1).getStartY(), innerCircle, shapesNumber, 1); innerCircleCurve.setControlX2(controlPoint.getX()); innerCircleCurve.setControlY2(controlPoint.getY()); // outer circle curve final CubicCurve outerCircleCurve = new CubicCurve(); outerCircleCurve.startXProperty().bind(curves.get(0).endXProperty()); outerCircleCurve.startYProperty().bind(curves.get(0).endYProperty()); outerCircleCurve.endXProperty().bind(curves.get(1).endXProperty()); outerCircleCurve.endYProperty().bind(curves.get(1).endYProperty()); controlPoint = makeControlPoint(curves.get(0).getEndX(), curves.get(0).getEndY(), outerCircle, shapesNumber, -1); outerCircleCurve.setControlX1(controlPoint.getX()); outerCircleCurve.setControlY1(controlPoint.getY()); controlPoint = makeControlPoint(curves.get(1).getEndX(), curves.get(1).getEndY(), outerCircle, shapesNumber, 1); outerCircleCurve.setControlX2(controlPoint.getX()); outerCircleCurve.setControlY2(controlPoint.getY()); startPoint = new MoveTo(); startPoint.xProperty().bind(curves.get(0).startXProperty()); startPoint.yProperty().bind(curves.get(0).startYProperty()); curve0To = new CubicCurveTo(); curve0To.controlX1Property().bind(curves.get(0).controlX1Property()); curve0To.controlY1Property().bind(curves.get(0).controlY1Property()); curve0To.controlX2Property().bind(curves.get(0).controlX2Property()); curve0To.controlY2Property().bind(curves.get(0).controlY2Property()); curve0To.xProperty().bind(curves.get(0).endXProperty()); curve0To.yProperty().bind(curves.get(0).endYProperty()); outerCircleCurveTo = new CubicCurveTo(); outerCircleCurveTo.controlX1Property().bind(outerCircleCurve.controlX1Property()); outerCircleCurveTo.controlY1Property().bind(outerCircleCurve.controlY1Property()); outerCircleCurveTo.controlX2Property().bind(outerCircleCurve.controlX2Property()); outerCircleCurveTo.controlY2Property().bind(outerCircleCurve.controlY2Property()); outerCircleCurveTo.xProperty().bind(outerCircleCurve.endXProperty()); outerCircleCurveTo.yProperty().bind(outerCircleCurve.endYProperty()); curve1To = new CubicCurveTo(); curve1To.controlX1Property().bind(curves.get(1).controlX2Property()); curve1To.controlY1Property().bind(curves.get(1).controlY2Property()); curve1To.controlX2Property().bind(curves.get(1).controlX1Property()); curve1To.controlY2Property().bind(curves.get(1).controlY1Property()); curve1To.xProperty().bind(curves.get(1).startXProperty()); curve1To.yProperty().bind(curves.get(1).startYProperty()); innerCircleCurveTo = new CubicCurveTo(); innerCircleCurveTo.controlX1Property().bind(innerCircleCurve.controlX2Property()); innerCircleCurveTo.controlY1Property().bind(innerCircleCurve.controlY2Property()); innerCircleCurveTo.controlX2Property().bind(innerCircleCurve.controlX1Property()); innerCircleCurveTo.controlY2Property().bind(innerCircleCurve.controlY1Property()); innerCircleCurveTo.xProperty().bind(innerCircleCurve.startXProperty()); innerCircleCurveTo.yProperty().bind(innerCircleCurve.startYProperty()); } private void createQuadraticCurve(final DoubleProperty rotationAngle, final double initControlX1, final double initControlY1) { for (int i = 0; i < 2; i++) { double angle = 2 * i * Math.PI / shapesNumber; double xOffset = radius * Math.cos(angle); double yOffset = radius * Math.sin(angle); final double startx = centerX + xOffset; final double starty = centerY + yOffset; final double diffStartCenterX = startx - centerX; final double diffStartCenterY = starty - centerY; final double sinRotAngle = Math.sin(rotationAngle.get()); final double cosRotAngle = Math.cos(rotationAngle.get()); final double startXR = cosRotAngle * diffStartCenterX - sinRotAngle * diffStartCenterY + centerX; final double startYR = sinRotAngle * diffStartCenterX + cosRotAngle * diffStartCenterY + centerY; angle = 2 * i * Math.PI / shapesNumber; xOffset = distance * Math.cos(angle); yOffset = distance * Math.sin(angle); final double endx = centerX + xOffset; final double endy = centerY + yOffset; final CubicCurve curvedLine = new CubicCurve(); curvedLine.setStartX(startXR); curvedLine.setStartY(startYR); curvedLine.setControlX1(startXR); curvedLine.setControlY1(startYR); curvedLine.setControlX2(endx); curvedLine.setControlY2(endy); curvedLine.setEndX(endx); curvedLine.setEndY(endy); curvedLine.setStroke(Color.FORESTGREEN); curvedLine.setStrokeWidth(1); curvedLine.setStrokeLineCap(StrokeLineCap.ROUND); curvedLine.setFill(Color.TRANSPARENT); curvedLine.setMouseTransparent(true); rotationAngle.addListener((o, oldVal, newVal) -> { final double newstartXR = ((cosRotAngle * diffStartCenterX) - (sinRotAngle * diffStartCenterY)) + centerX; final double newstartYR = (sinRotAngle * diffStartCenterX) + (cosRotAngle * diffStartCenterY) + centerY; curvedLine.setStartX(newstartXR); curvedLine.setStartY(newstartYR); }); curves.add(curvedLine); if (i == 0) { curvedLine.setControlX1(initControlX1); curvedLine.setControlY1(initControlY1); } else { final CubicCurve firstCurve = curves.get(0); final double curveTheta = 2 * curves.indexOf(curvedLine) * Math.PI / shapesNumber; curvedLine.controlX1Property().bind(Bindings.createDoubleBinding(() -> { final double a = firstCurve.getControlX1() - centerX; final double b = Math.sin(curveTheta) * (firstCurve.getControlY1() - centerY); return ((Math.cos(curveTheta) * a) - b) + centerX; }, firstCurve.controlX1Property(), firstCurve.controlY1Property())); curvedLine.controlY1Property().bind(Bindings.createDoubleBinding(() -> { final double a = Math.sin(curveTheta) * (firstCurve.getControlX1() - centerX); final double b = Math.cos(curveTheta) * (firstCurve.getControlY1() - centerY); return a + b + centerY; }, firstCurve.controlX1Property(), firstCurve.controlY1Property())); curvedLine.controlX2Property().bind(Bindings.createDoubleBinding(() -> { final double a = firstCurve.getControlX2() - centerX; final double b = firstCurve.getControlY2() - centerY; return ((Math.cos(curveTheta) * a) - (Math.sin(curveTheta) * b)) + centerX; }, firstCurve.controlX2Property(), firstCurve.controlY2Property())); curvedLine.controlY2Property().bind(Bindings.createDoubleBinding(() -> { final double a = Math.sin(curveTheta) * (firstCurve.getControlX2() - centerX); final double b = Math.cos(curveTheta) * (firstCurve.getControlY2() - centerY); return a + b + centerY; }, firstCurve.controlX2Property(), firstCurve.controlY2Property())); } } } private String getDefaultColor(final int i) { String color = "#FFFFFF"; switch (i) { case 0: color = "#8F3F7E"; break; case 1: color = "#B5305F"; break; case 2: color = "#CE584A"; break; case 3: color = "#DB8D5C"; break; case 4: color = "#DA854E"; break; case 5: color = "#E9AB44"; break; case 6: color = "#FEE435"; break; case 7: color = "#99C286"; break; case 8: color = "#01A05E"; break; case 9: color = "#4A8895"; break; case 10: color = "#16669B"; break; case 11: color = "#2F65A5"; break; case 12: color = "#4E6A9C"; break; default: break; } return color; } private Point2D rotate(final Point2D a, final Point2D center, final double angle) { final double resultX = center.getX() + (a.getX() - center.getX()) * Math.cos(angle) - (a.getY() - center.getY()) * Math .sin(angle); final double resultY = center.getY() + (a.getX() - center.getX()) * Math.sin(angle) + (a.getY() - center.getY()) * Math .cos(angle); return new Point2D(resultX, resultY); } private Point2D makeControlPoint(final double endX, final double endY, final Circle circle, final int numSegments, int direction) { final double controlPointDistance = (4.0 / 3.0) * Math.tan(Math.PI / (2 * numSegments)) * circle.getRadius(); final Point2D center = new Point2D(circle.getCenterX(), circle.getCenterY()); final Point2D end = new Point2D(endX, endY); Point2D perp = rotate(center, end, direction * Math.PI / 2.); Point2D diff = perp.subtract(end); diff = diff.normalize(); diff = scale(diff, controlPointDistance); return end.add(diff); } private Point2D scale(final Point2D a, final double scale) { return new Point2D(a.getX() * scale, a.getY() * scale); } final class RecentColorPath extends Path { PathClickTransition transition; RecentColorPath(final PathElement... elements) { super(elements); this.setStrokeLineCap(StrokeLineCap.ROUND); this.setStrokeWidth(0); this.setStrokeType(StrokeType.CENTERED); this.setCache(true); JFXDepthManager.setDepth(this, 2); this.transition = new PathClickTransition(this); } void playTransition(final double rate) { transition.setRate(rate); transition.play(); } } private final class PathClickTransition extends CachedTransition { PathClickTransition(final Path path) { super(JFXCustomColorPicker.this, new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(((DropShadow) path.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(2).radiusProperty().get(), EASE_BOTH), new KeyValue(((DropShadow) path.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(2).spreadProperty().get(), EASE_BOTH), new KeyValue(((DropShadow) path.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(2).offsetXProperty().get(), EASE_BOTH), new KeyValue(((DropShadow) path.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(2).offsetYProperty().get(), EASE_BOTH), new KeyValue(path.strokeWidthProperty(), 0, EASE_BOTH) ), new KeyFrame(Duration.millis(1000), new KeyValue(((DropShadow) path.getEffect()).radiusProperty(), JFXDepthManager.getShadowAt(5).radiusProperty().get(), EASE_BOTH), new KeyValue(((DropShadow) path.getEffect()).spreadProperty(), JFXDepthManager.getShadowAt(5).spreadProperty().get(), EASE_BOTH), new KeyValue(((DropShadow) path.getEffect()).offsetXProperty(), JFXDepthManager.getShadowAt(5).offsetXProperty().get(), EASE_BOTH), new KeyValue(((DropShadow) path.getEffect()).offsetYProperty(), JFXDepthManager.getShadowAt(5).offsetYProperty().get(), EASE_BOTH), new KeyValue(path.strokeWidthProperty(), 2, EASE_BOTH) ) ) ); // reduce the number to increase the shifting , increase number to reduce shifting setCycleDuration(Duration.millis(120)); setDelay(Duration.seconds(0)); setAutoReverse(false); } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXCustomColorPickerDialog.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.*; import com.jfoenix.svg.SVGGlyph; import com.jfoenix.transitions.JFXFillTransition; import javafx.animation.*; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.geometry.Rectangle2D; import javafx.scene.Scene; import javafx.scene.control.Tab; import javafx.scene.control.TextFormatter; import javafx.scene.input.KeyEvent; import javafx.scene.layout.*; import javafx.scene.paint.Color; import javafx.stage.*; import javafx.util.Duration; import org.jackhuang.hmcl.setting.StyleSheets; import org.jackhuang.hmcl.util.StringUtils; import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; /** * @author Shadi Shaheen */ public class JFXCustomColorPickerDialog extends StackPane { private final Stage dialog = new Stage(); // used for concurrency control and preventing FX-thread over use private final AtomicInteger concurrencyController = new AtomicInteger(-1); private final ObjectProperty currentColorProperty = new SimpleObjectProperty<>(Color.WHITE); private final ObjectProperty customColorProperty = new SimpleObjectProperty<>(Color.TRANSPARENT); private Runnable onSave; private final Scene customScene; private final JFXCustomColorPicker curvedColorPicker; private ParallelTransition paraTransition; private final JFXDecorator pickerDecorator; private boolean systemChange = false; private boolean userChange = false; private boolean initOnce = true; private final Runnable initRun; public JFXCustomColorPickerDialog(Window owner) { getStyleClass().add("custom-color-dialog"); if (owner != null) { dialog.initOwner(owner); } dialog.initModality(Modality.APPLICATION_MODAL); dialog.initStyle(StageStyle.TRANSPARENT); dialog.setResizable(false); // create JFX Decorator pickerDecorator = new JFXDecorator(dialog, this, false, false, false); pickerDecorator.setOnCloseButtonAction(this::updateColor); pickerDecorator.setPickOnBounds(false); customScene = new Scene(pickerDecorator, Color.TRANSPARENT); StyleSheets.init(customScene); curvedColorPicker = new JFXCustomColorPicker(); StackPane pane = new StackPane(curvedColorPicker); pane.setPadding(new Insets(18)); VBox container = new VBox(); container.getChildren().add(pane); JFXTabPane tabs = new JFXTabPane(); JFXTextField rgbField = new JFXTextField(); JFXTextField hsbField = new JFXTextField(); JFXTextField hexField = new JFXTextField(); rgbField.getStyleClass().add("custom-color-field"); rgbField.setPromptText("RGB Color"); rgbField.setTextFormatter(colorCharFormatter()); rgbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal)); hsbField.getStyleClass().add("custom-color-field"); hsbField.setPromptText("HSB Color"); hsbField.setTextFormatter(colorCharFormatter()); hsbField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal)); hexField.getStyleClass().add("custom-color-field"); hexField.setPromptText("#HEX Color"); hexField.setTextFormatter(colorCharFormatter()); hexField.textProperty().addListener((o, oldVal, newVal) -> updateColorFromUserInput(newVal)); StackPane tabContent = new StackPane(); tabContent.getChildren().add(rgbField); tabContent.setMinHeight(100); Tab rgbTab = new Tab("RGB"); rgbTab.setContent(tabContent); Tab hsbTab = new Tab("HSB"); hsbTab.setContent(hsbField); Tab hexTab = new Tab("HEX"); hexTab.setContent(hexField); tabs.getTabs().add(hexTab); tabs.getTabs().add(rgbTab); tabs.getTabs().add(hsbTab); curvedColorPicker.selectedPath.addListener((o, oldVal, newVal) -> { if (paraTransition != null) { paraTransition.stop(); } Region tabsHeader = (Region) tabs.lookup(".tab-header-background"); pane.backgroundProperty().unbind(); tabsHeader.backgroundProperty().unbind(); JFXFillTransition fillTransition = new JFXFillTransition(Duration.millis(240), pane, (Color) oldVal.getFill(), (Color) newVal.getFill()); JFXFillTransition tabsFillTransition = new JFXFillTransition(Duration.millis(240), tabsHeader, (Color) oldVal.getFill(), (Color) newVal.getFill()); paraTransition = new ParallelTransition(fillTransition, tabsFillTransition); paraTransition.setOnFinished((finish) -> { tabsHeader.backgroundProperty().bind(Bindings.createObjectBinding(() -> { return new Background(new BackgroundFill(newVal.getFill(), CornerRadii.EMPTY, Insets.EMPTY)); }, newVal.fillProperty())); pane.backgroundProperty().bind(Bindings.createObjectBinding(() -> { return new Background(new BackgroundFill(newVal.getFill(), CornerRadii.EMPTY, Insets.EMPTY)); }, newVal.fillProperty())); }); paraTransition.play(); }); initRun = () -> { // change tabs labels font color according to the selected color pane.backgroundProperty().addListener((o, oldVal, newVal) -> { if (concurrencyController.getAndSet(1) == -1) { Color fontColor = ((Color) newVal.getFills().get(0).getFill()).grayscale() .getRed() > 0.5 ? Color.valueOf( "rgba(40, 40, 40, 0.87)") : Color.valueOf("rgba(255, 255, 255, 0.87)"); // for (Node tabNode : tabs.lookupAll(".tab")) { // for (Node node : tabNode.lookupAll(".tab-label")) { // ((Label) node).setTextFill(fontColor); // } // for (Node node : tabNode.lookupAll(".jfx-rippler")) { // ((JFXRippler) node).setRipplerFill(fontColor); // } // } // ((Pane) tabs.lookup(".tab-selected-line")).setBackground(new Background(new BackgroundFill(fontColor, CornerRadii.EMPTY, Insets.EMPTY))); pickerDecorator.lookupAll(".jfx-decorator-button").forEach(button -> { ((JFXButton) button).setRipplerFill(fontColor); ((SVGGlyph) ((JFXButton) button).getGraphic()).setFill(fontColor); }); Color newColor = (Color) newVal.getFills().get(0).getFill(); String hex = String.format("#%02X%02X%02X", (int) (newColor.getRed() * 255), (int) (newColor.getGreen() * 255), (int) (newColor.getBlue() * 255)); String rgb = String.format("rgba(%d, %d, %d, 1)", (int) (newColor.getRed() * 255), (int) (newColor.getGreen() * 255), (int) (newColor.getBlue() * 255)); String hsb = String.format("hsl(%d, %d%%, %d%%)", (int) (newColor.getHue()), (int) (newColor.getSaturation() * 100), (int) (newColor.getBrightness() * 100)); if (!userChange) { systemChange = true; rgbField.setText(rgb); hsbField.setText(hsb); hexField.setText(hex); systemChange = false; } concurrencyController.getAndSet(-1); } }); // initial selected colors Platform.runLater(() -> { pane.setBackground(new Background(new BackgroundFill(curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()), CornerRadii.EMPTY, Insets.EMPTY))); ((Region) tabs.lookup(".tab-header-background")).setBackground(new Background(new BackgroundFill( curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex()), CornerRadii.EMPTY, Insets.EMPTY))); Region tabsHeader = (Region) tabs.lookup(".tab-header-background"); pane.backgroundProperty().unbind(); tabsHeader.backgroundProperty().unbind(); tabsHeader.backgroundProperty().bind(Bindings.createObjectBinding(() -> { return new Background(new BackgroundFill(curvedColorPicker.selectedPath.get().getFill(), CornerRadii.EMPTY, Insets.EMPTY)); }, curvedColorPicker.selectedPath.get().fillProperty())); pane.backgroundProperty().bind(Bindings.createObjectBinding(() -> { return new Background(new BackgroundFill(curvedColorPicker.selectedPath.get().getFill(), CornerRadii.EMPTY, Insets.EMPTY)); }, curvedColorPicker.selectedPath.get().fillProperty())); // bind text field line color rgbField.focusColorProperty().bind(Bindings.createObjectBinding(() -> { return pane.getBackground().getFills().get(0).getFill(); }, pane.backgroundProperty())); hsbField.focusColorProperty().bind(Bindings.createObjectBinding(() -> { return pane.getBackground().getFills().get(0).getFill(); }, pane.backgroundProperty())); hexField.focusColorProperty().bind(Bindings.createObjectBinding(() -> { return pane.getBackground().getFills().get(0).getFill(); }, pane.backgroundProperty())); ((Pane) pickerDecorator.lookup(".jfx-decorator-buttons-container")).backgroundProperty() .bind(Bindings.createObjectBinding(() -> { return new Background(new BackgroundFill( pane.getBackground() .getFills() .get(0) .getFill(), CornerRadii.EMPTY, Insets.EMPTY)); }, pane.backgroundProperty())); ((Pane) pickerDecorator.lookup(".jfx-decorator-content-container")).borderProperty() .bind(Bindings.createObjectBinding(() -> { return new Border(new BorderStroke( pane.getBackground() .getFills() .get(0) .getFill(), BorderStrokeStyle.SOLID, CornerRadii.EMPTY, new BorderWidths(0, 4, 4, 4))); }, pane.backgroundProperty())); }); }; container.getChildren().add(tabs); this.getChildren().add(container); this.setPadding(new Insets(0)); dialog.setScene(customScene); final EventHandler keyEventListener = key -> { switch (key.getCode()) { case ESCAPE: close(); break; case ENTER: updateColor(); break; default: break; } }; dialog.addEventHandler(KeyEvent.ANY, keyEventListener); } private void updateColor() { close(); this.customColorProperty.set(curvedColorPicker.getColor(curvedColorPicker.getSelectedIndex())); this.onSave.run(); } private void updateColorFromUserInput(String colorWebString) { if (!systemChange) { userChange = true; try { curvedColorPicker.setColor(Color.valueOf(colorWebString)); } catch (IllegalArgumentException ignored) { // if color is not valid then do nothing } userChange = false; } } private void close() { dialog.setScene(null); dialog.close(); } public void setCurrentColor(Color currentColor) { this.currentColorProperty.set(currentColor); if (curvedColorPicker != null && currentColor != null) { curvedColorPicker.setColor(currentColor); } } Color getCurrentColor() { return currentColorProperty.get(); } ObjectProperty customColorProperty() { return customColorProperty; } void setCustomColor(Color color) { customColorProperty.set(color); } Color getCustomColor() { return customColorProperty.get(); } public Runnable getOnSave() { return onSave; } public void setOnSave(Runnable onSave) { this.onSave = onSave; } public void setOnHidden(EventHandler onHidden) { dialog.setOnHidden(onHidden); } public void show() { dialog.setOpacity(0); // pickerDecorator.setOpacity(0); if (dialog.getOwner() != null) { dialog.widthProperty().addListener(positionAdjuster); dialog.heightProperty().addListener(positionAdjuster); positionAdjuster.invalidated(null); } if (dialog.getScene() == null) { dialog.setScene(customScene); } curvedColorPicker.preAnimate(); dialog.show(); if (initOnce) { initRun.run(); initOnce = false; } Timeline timeline = new Timeline(new KeyFrame(Duration.millis(120), new KeyValue(dialog.opacityProperty(), 1, Interpolator.EASE_BOTH))); timeline.setOnFinished((finish) -> curvedColorPicker.animate()); timeline.play(); } // add option to show color picker using JFX Dialog private InvalidationListener positionAdjuster = new InvalidationListener() { @Override public void invalidated(Observable ignored) { if (Double.isNaN(dialog.getWidth()) || Double.isNaN(dialog.getHeight())) { return; } dialog.widthProperty().removeListener(positionAdjuster); dialog.heightProperty().removeListener(positionAdjuster); fixPosition(); } }; private void fixPosition() { Window w = dialog.getOwner(); Screen s = com.sun.javafx.util.Utils.getScreen(w); Rectangle2D sb = s.getBounds(); double xR = w.getX() + w.getWidth(); double xL = w.getX() - dialog.getWidth(); double x; double y; if (sb.getMaxX() >= xR + dialog.getWidth()) { x = xR; } else if (sb.getMinX() <= xL) { x = xL; } else { x = Math.max(sb.getMinX(), sb.getMaxX() - dialog.getWidth()); } y = Math.max(sb.getMinY(), Math.min(sb.getMaxY() - dialog.getHeight(), w.getY())); dialog.setX(x); dialog.setY(y); } @Override public void layoutChildren() { super.layoutChildren(); if (dialog.getMinWidth() > 0 && dialog.getMinHeight() > 0) { return; } double minWidth = Math.max(0, computeMinWidth(getHeight()) + (dialog.getWidth() - customScene.getWidth())); double minHeight = Math.max(0, computeMinHeight(getWidth()) + (dialog.getHeight() - customScene.getHeight())); dialog.setMinWidth(minWidth); dialog.setMinHeight(minHeight); } private static final Pattern COLOR_CHAR_PATTERN = Pattern.compile("[0-9a-zA-Z#(),%.\\s]*"); private static TextFormatter colorCharFormatter() { return new TextFormatter<>(change -> { if (!change.isContentChange()) return change; String ins = StringUtils.toHalfWidth(change.getText()); if (!COLOR_CHAR_PATTERN.matcher(ins).matches()) return null; String full = StringUtils.toHalfWidth(change.getControlNewText()); long h = full.chars().filter(c -> c == '#').count(); if (h > 1 || (h == 1 && full.indexOf('#') != 0)) return null; change.setText(ins); return change; }); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXGenericPickerSkin.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.adapters.ReflectionHelper; import com.jfoenix.controls.behavior.JFXGenericPickerBehavior; import com.sun.javafx.binding.ExpressionHelper; import com.sun.javafx.event.EventHandlerManager; import com.sun.javafx.stage.WindowEventDispatcher; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.ReadOnlyBooleanPropertyBase; import javafx.beans.value.ChangeListener; import javafx.event.EventHandler; import javafx.event.EventType; import javafx.scene.control.ComboBoxBase; import javafx.scene.control.PopupControl; import javafx.scene.control.TextField; import javafx.scene.control.skin.ComboBoxBaseSkin; import javafx.scene.control.skin.ComboBoxPopupControl; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.stage.Window; import java.lang.invoke.MethodHandles; import java.lang.invoke.VarHandle; import java.lang.reflect.Method; import java.util.HashMap; import java.util.Map; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public abstract class JFXGenericPickerSkin extends ComboBoxPopupControl { private final EventHandler mouseEnteredEventHandler; private final EventHandler mousePressedEventHandler; private final EventHandler mouseReleasedEventHandler; private final EventHandler mouseExitedEventHandler; protected JFXGenericPickerBehavior behavior; // reference of the arrow button node in getChildren (not the actual field) protected Pane arrowButton; protected PopupControl popup; public JFXGenericPickerSkin(ComboBoxBase comboBoxBase) { super(comboBoxBase); behavior = new JFXGenericPickerBehavior(comboBoxBase); removeParentFakeFocusListener(comboBoxBase); this.mouseEnteredEventHandler = event -> behavior.mouseEntered(event); this.mousePressedEventHandler = event -> { behavior.mousePressed(event); event.consume(); }; this.mouseReleasedEventHandler = event -> { behavior.mouseReleased(event); event.consume(); }; this.mouseExitedEventHandler = event -> behavior.mouseExited(event); arrowButton = (Pane) getChildren().get(0); parentArrowEventHandlerTerminator.accept("mouseEnteredEventHandler", MouseEvent.MOUSE_ENTERED); parentArrowEventHandlerTerminator.accept("mousePressedEventHandler", MouseEvent.MOUSE_PRESSED); parentArrowEventHandlerTerminator.accept("mouseReleasedEventHandler", MouseEvent.MOUSE_RELEASED); parentArrowEventHandlerTerminator.accept("mouseExitedEventHandler", MouseEvent.MOUSE_EXITED); this.unregisterChangeListeners(comboBoxBase.editableProperty()); updateArrowButtonListeners(); registerChangeListener(comboBoxBase.editableProperty(), obs -> { updateArrowButtonListeners(); reflectUpdateDisplayArea(); }); removeParentPopupHandlers(); popup = ReflectionHelper.getFieldContent(ComboBoxPopupControl.class, this, "popup"); } @Override public void dispose() { super.dispose(); if (this.behavior != null) { this.behavior.dispose(); } } /*************************************************************************** * * * Reflections internal API * * * **************************************************************************/ private final BiConsumer> parentArrowEventHandlerTerminator = (handlerName, eventType) -> { try { EventHandler handler = ReflectionHelper.getFieldContent(ComboBoxBaseSkin.class, this, handlerName); arrowButton.removeEventHandler(eventType, handler); } catch (Exception e) { e.printStackTrace(); } }; private static final VarHandle READ_ONLY_BOOLEAN_PROPERTY_BASE_HELPER = findVarHandle(ReadOnlyBooleanPropertyBase.class, "helper", ExpressionHelper.class); /// @author Glavo private static VarHandle findVarHandle(Class targetClass, String fieldName, Class type) { try { return MethodHandles.privateLookupIn(targetClass, MethodHandles.lookup()).findVarHandle(targetClass, fieldName, type); } catch (NoSuchFieldException | IllegalAccessException e) { LOG.warning("Failed to get var handle", e); return null; } } private void removeParentFakeFocusListener(ComboBoxBase comboBoxBase) { // handle FakeFocusField cast exception try { final ReadOnlyBooleanProperty focusedProperty = comboBoxBase.focusedProperty(); //noinspection unchecked ExpressionHelper value = (ExpressionHelper) READ_ONLY_BOOLEAN_PROPERTY_BASE_HELPER.get(focusedProperty); ChangeListener[] changeListeners = ReflectionHelper.getFieldContent(value.getClass(), value, "changeListeners"); // remove parent focus listener to prevent editor class cast exception for (int i = changeListeners.length - 1; i > 0; i--) { if (changeListeners[i] != null && changeListeners[i].getClass().getName().contains("ComboBoxPopupControl")) { focusedProperty.removeListener(changeListeners[i]); break; } } } catch (Exception e) { e.printStackTrace(); } } private void removeParentPopupHandlers() { try { PopupControl popup = ReflectionHelper.invoke(ComboBoxPopupControl.class, this, "getPopup"); popup.setOnAutoHide(event -> behavior.onAutoHide(popup)); WindowEventDispatcher dispatcher = ReflectionHelper.invoke(Window.class, popup, "getInternalEventDispatcher"); Map compositeEventHandlersMap = ReflectionHelper.getFieldContent(EventHandlerManager.class, dispatcher.getEventHandlerManager(), "eventHandlerMap"); compositeEventHandlersMap.remove(MouseEvent.MOUSE_CLICKED); // CompositeEventHandler compositeEventHandler = (CompositeEventHandler) compositeEventHandlersMap.get(MouseEvent.MOUSE_CLICKED); // Object obj = fieldConsumer.apply(()->CompositeEventHandler.class.getDeclaredField("firstRecord"),compositeEventHandler); // EventHandler handler = (EventHandler) fieldConsumer.apply(() -> obj.getClass().getDeclaredField("eventHandler"), obj); // popup.removeEventHandler(MouseEvent.MOUSE_CLICKED, handler); popup.addEventHandler(MouseEvent.MOUSE_CLICKED, click -> behavior.onAutoHide(popup)); } catch (Exception e) { e.printStackTrace(); } } private void updateArrowButtonListeners() { if (getSkinnable().isEditable()) { arrowButton.addEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); arrowButton.addEventHandler(MouseEvent.MOUSE_PRESSED, mousePressedEventHandler); arrowButton.addEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler); arrowButton.addEventHandler(MouseEvent.MOUSE_EXITED, mouseExitedEventHandler); } else { arrowButton.removeEventHandler(MouseEvent.MOUSE_ENTERED, mouseEnteredEventHandler); arrowButton.removeEventHandler(MouseEvent.MOUSE_PRESSED, mousePressedEventHandler); arrowButton.removeEventHandler(MouseEvent.MOUSE_RELEASED, mouseReleasedEventHandler); arrowButton.removeEventHandler(MouseEvent.MOUSE_EXITED, mouseExitedEventHandler); } } /*************************************************************************** * * * Reflections internal API for ComboBoxPopupControl * * * **************************************************************************/ private final HashMap parentCachedMethods = new HashMap<>(); Function methodSupplier = name -> { if (!parentCachedMethods.containsKey(name)) { try { Method method = ComboBoxPopupControl.class.getDeclaredMethod(name); method.setAccessible(true); parentCachedMethods.put(name, method); } catch (Exception e) { e.printStackTrace(); } } return parentCachedMethods.get(name); }; final Consumer methodInvoker = method -> { try { method.invoke(this); } catch (Exception e) { e.printStackTrace(); } }; final Function methodReturnInvoker = method -> { try { return method.invoke(this); } catch (Exception e) { e.printStackTrace(); } return null; }; protected void reflectUpdateDisplayArea() { methodInvoker.accept(methodSupplier.apply("updateDisplayArea")); } protected void reflectSetTextFromTextFieldIntoComboBoxValue() { methodInvoker.accept(methodSupplier.apply("setTextFromTextFieldIntoComboBoxValue")); } protected TextField reflectGetEditableInputNode() { return (TextField) methodReturnInvoker.apply(methodSupplier.apply("getEditableInputNode")); } protected void reflectUpdateDisplayNode() { methodInvoker.accept(methodSupplier.apply("updateDisplayNode")); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXListViewSkin.java ================================================ // Copy from https://github.com/sshahine/JFoenix/blob/d427fd801a338f934307ba41ce604eb5c79f0b20/jfoenix/src/main/java/com/jfoenix/skins/JFXListViewSkin.java /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXListView; import com.jfoenix.effects.JFXDepthManager; import javafx.scene.control.ListCell; import javafx.scene.control.skin.ListViewSkin; import javafx.scene.control.skin.VirtualFlow; import org.jackhuang.hmcl.ui.FXUtils; public class JFXListViewSkin extends ListViewSkin { public JFXListViewSkin(final JFXListView listView) { super(listView); VirtualFlow> flow = getVirtualFlow(); FXUtils.onChangeAndOperate(listView.depthProperty(), depth -> JFXDepthManager.setDepth(flow, depth.intValue())); if (!Boolean.TRUE.equals(listView.getProperties().get("no-smooth-scrolling"))) { FXUtils.smoothScrolling(flow); } } @Override protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return 200; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXPopupSkin.java ================================================ /* * Copyright (c) 2016 JFoenix * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in * the Software without restriction, including without limitation the rights to * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of * the Software, and to permit persons to whom the Software is furnished to do so, * subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXPopup; import com.jfoenix.controls.JFXPopup.PopupHPosition; import com.jfoenix.controls.JFXPopup.PopupVPosition; import com.jfoenix.effects.JFXDepthManager; import javafx.animation.*; import javafx.animation.Animation.Status; import javafx.scene.Node; import javafx.scene.control.Skin; import javafx.scene.layout.*; import javafx.scene.transform.Scale; import javafx.util.Duration; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.Motion; /// # Material Design Popup Skin /// TODO: REWORK /// /// @author Shadi Shaheen /// @version 2.0 /// @since 2017-03-01 public class JFXPopupSkin implements Skin { protected JFXPopup control; protected StackPane container = new StackPane(); protected Region popupContent; protected Node root; private Animation animation; protected Scale scale; public JFXPopupSkin(JFXPopup control) { this.control = control; // set scale y to 0.01 instead of 0 to allow layout of the content, // otherwise it will cause exception in traverse engine, when focusing the 1st node scale = new Scale(1.0, 0.01, 0, 0); popupContent = control.getPopupContent(); container.getStyleClass().add("jfx-popup-container"); container.getChildren().add(popupContent); container.getTransforms().add(scale); container.setOpacity(0); root = JFXDepthManager.createMaterialNode(container, 4); animation = AnimationUtils.isAnimationEnabled() ? getAnimation() : null; } public void reset(PopupVPosition vAlign, PopupHPosition hAlign, double offsetX, double offsetY) { // postion the popup according to its animation scale.setPivotX(hAlign == PopupHPosition.RIGHT ? container.getWidth() : 0); scale.setPivotY(vAlign == PopupVPosition.BOTTOM ? container.getHeight() : 0); control.setX(control.getX() + (hAlign == PopupHPosition.RIGHT ? -container.getWidth() + offsetX : offsetX)); control.setY(control.getY() + (vAlign == PopupVPosition.BOTTOM ? -container.getHeight() + offsetY : offsetY)); } public final void animate() { if (animation != null) { if (animation.getStatus() == Status.STOPPED) { container.setOpacity(1); animation.playFromStart(); } } else { container.setOpacity(1); popupContent.setOpacity(1); scale.setX(1.0); scale.setY(1.0); } } @Override public JFXPopup getSkinnable() { return control; } @Override public Node getNode() { return root; } @Override public void dispose() { if (animation != null) { animation.stop(); animation = null; } container = null; control = null; popupContent = null; root = null; } protected Animation getAnimation() { Interpolator interpolator = Motion.EASE; return new Timeline( new KeyFrame( Duration.ZERO, new KeyValue(popupContent.opacityProperty(), 0, interpolator), new KeyValue(scale.xProperty(), 0, interpolator), new KeyValue(scale.yProperty(), 0, interpolator) ), new KeyFrame(Motion.SHORT4, new KeyValue(popupContent.opacityProperty(), 0, interpolator), new KeyValue(scale.xProperty(), 1, interpolator) ), new KeyFrame(Motion.MEDIUM2, new KeyValue(popupContent.opacityProperty(), 1, interpolator), new KeyValue(scale.yProperty(), 1, interpolator) ) ); } public void init() { if (animation != null) animation.stop(); container.setOpacity(0); scale.setX(1.0); scale.setY(0.01); } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXProgressBarSkin.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXProgressBar; import com.jfoenix.utils.TreeShowingProperty; import javafx.animation.*; import javafx.scene.Node; import javafx.scene.control.SkinBase; import javafx.scene.layout.*; import javafx.scene.shape.Rectangle; import javafx.util.Duration; import org.jackhuang.hmcl.ui.animation.AnimationUtils; /// # Material Design ProgressBar Skin /// /// @author Shadi Shaheen /// @version 2.0 /// @since 2017-10-06 public class JFXProgressBarSkin extends SkinBase { private static final double DEFAULT_HEIGHT = 4; private final StackPane track; private final StackPane bar; private final Rectangle clip; private Animation transition; private final TreeShowingProperty treeShowingProperty; private double fullWidth; public JFXProgressBarSkin(JFXProgressBar control) { super(control); this.treeShowingProperty = new TreeShowingProperty(control); registerChangeListener(treeShowingProperty, obs -> updateProgress(false)); registerChangeListener(control.progressProperty(), obs -> updateProgress(true)); track = new StackPane(); track.getStyleClass().setAll("track"); bar = new StackPane(); bar.getStyleClass().setAll("bar"); clip = new Rectangle(); clip.setArcWidth(DEFAULT_HEIGHT); clip.setArcHeight(DEFAULT_HEIGHT); bar.setClip(clip); getChildren().setAll(track, bar); getSkinnable().requestLayout(); } @Override public double computeBaselineOffset(double topInset, double rightInset, double bottomInset, double leftInset) { return Node.BASELINE_OFFSET_SAME_AS_HEIGHT; } @Override protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return Math.max(100, leftInset + bar.prefWidth(getSkinnable().getWidth()) + rightInset); } @Override protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { return topInset + DEFAULT_HEIGHT + bottomInset; } @Override protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return getSkinnable().prefWidth(height); } @Override protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { return getSkinnable().prefHeight(width); } @Override protected void layoutChildren(double x, double y, double w, double h) { track.resizeRelocate(x, y, w, h); bar.resizeRelocate(x, y, w, h); clip.relocate(0, 0); clip.setWidth(0); clip.setHeight(h); clip.setTranslateX(0); fullWidth = w; clearAnimation(); updateProgress(false); } private boolean wasIndeterminate = false; private void updateProgress(boolean playProgressAnimation) { double progress = Math.min(getSkinnable().getProgress(), 1.0); boolean isIndeterminate = progress < 0.0; boolean isTreeShowing = treeShowingProperty.get(); if (isIndeterminate != wasIndeterminate) { wasIndeterminate = isIndeterminate; clearAnimation(); clip.setTranslateX(0); } if (isIndeterminate) { // indeterminate if (isTreeShowing) { if (transition == null) { transition = createIndeterminateTransition(); transition.playFromStart(); } else { transition.play(); } } else if (transition != null) { transition.pause(); } } else { // determinate clearAnimation(); if (isTreeShowing && playProgressAnimation && AnimationUtils.isAnimationEnabled() && getSkinnable().isSmoothProgress()) { transition = createDeterminateTransition(progress); transition.playFromStart(); } else { clip.setWidth(computeBarWidth(progress)); } } } private static final Duration INDETERMINATE_DURATION = Duration.seconds(1); private Transition createIndeterminateTransition() { double minWidth = 0; double maxWidth = fullWidth * 0.4; Transition transition = new Transition() { { setInterpolator(Interpolator.LINEAR); setCycleDuration(INDETERMINATE_DURATION); } @Override protected void interpolate(double frac) { double currentWidth; if (frac <= 0.5) { currentWidth = Interpolator.EASE_IN.interpolate(minWidth, maxWidth, frac / 0.5); } else { currentWidth = Interpolator.EASE_OUT.interpolate(maxWidth, minWidth, (frac - 0.5) / 0.5); } double targetCenter; if (frac <= 0.1) { targetCenter = 0.0; } else if (frac >= 0.9) { targetCenter = fullWidth; } else { targetCenter = ((frac - 0.1) / 0.8) * fullWidth; } clip.setWidth(currentWidth); clip.setTranslateX(targetCenter - currentWidth / 2.0); } }; transition.setCycleCount(Timeline.INDEFINITE); return transition; } private static final Duration DETERMINATE_DURATION = Duration.seconds(0.2); private Timeline createDeterminateTransition(double targetProgress) { Timeline timeline = new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(clip.widthProperty(), clip.getWidth(), Interpolator.EASE_OUT)), new KeyFrame(DETERMINATE_DURATION, new KeyValue(clip.widthProperty(), computeBarWidth(targetProgress), Interpolator.EASE_OUT)) ); timeline.setOnFinished(e -> { if (transition == timeline) { transition = null; } }); return timeline; } private void clearAnimation() { if (transition != null) { transition.stop(); transition = null; } } @Override public void dispose() { super.dispose(); treeShowingProperty.dispose(); clearAnimation(); } private double computeBarWidth(double progress) { assert progress >= 0 && progress <= 1; double barWidth = ((int) fullWidth * 2 * progress) / 2.0; return progress > 0 ? Math.max(barWidth, DEFAULT_HEIGHT) : barWidth; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXRadioButtonSkin.java ================================================ // // Source code recreated from a .class file by IntelliJ IDEA // (powered by FernFlower decompiler) // package com.jfoenix.skins; import com.jfoenix.controls.JFXRadioButton; import com.jfoenix.controls.JFXRippler; import com.jfoenix.controls.JFXRippler.RipplerMask; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.geometry.HPos; import javafx.geometry.Insets; import javafx.geometry.VPos; import javafx.scene.control.RadioButton; import javafx.scene.control.skin.RadioButtonSkin; import javafx.scene.layout.AnchorPane; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Circle; import javafx.scene.text.Text; import javafx.util.Duration; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.animation.AnimationUtils; public class JFXRadioButtonSkin extends RadioButtonSkin { private static final double PADDING = 15.0; private boolean invalid = true; private final JFXRippler rippler; private final Circle radio; private final Circle dot; private Timeline timeline; private final AnchorPane container = new AnchorPane(); private final double labelOffset = -10.0; public JFXRadioButtonSkin(JFXRadioButton control) { super(control); double radioRadius = 7.0; this.radio = new Circle(radioRadius); this.radio.getStyleClass().setAll("radio"); this.radio.setStrokeWidth(2.0); this.radio.setFill(Color.TRANSPARENT); this.dot = new Circle(4); this.dot.getStyleClass().setAll("dot"); this.dot.fillProperty().bind(control.selectedColorProperty()); this.dot.setScaleX(0.0); this.dot.setScaleY(0.0); StackPane boxContainer = new StackPane(); boxContainer.getChildren().addAll(this.radio, this.dot); boxContainer.setPadding(new Insets(PADDING)); this.rippler = new JFXRippler(boxContainer, RipplerMask.CIRCLE); this.container.getChildren().add(this.rippler); AnchorPane.setRightAnchor(this.rippler, this.labelOffset); this.updateChildren(); ReadOnlyBooleanProperty focusVisibleProperty = FXUtils.focusVisibleProperty(control); if (focusVisibleProperty == null) { focusVisibleProperty = control.focusedProperty(); } focusVisibleProperty.addListener((o, oldVal, newVal) -> { if (newVal) { if (!this.getSkinnable().isPressed()) { this.rippler.showOverlay(); } } else { this.rippler.hideOverlay(); } }); control.pressedProperty().addListener((o, oldVal, newVal) -> this.rippler.hideOverlay()); this.registerChangeListener(control.selectedColorProperty(), ignored -> updateColors()); this.registerChangeListener(control.unSelectedColorProperty(), ignored -> updateColors()); this.registerChangeListener(control.selectedProperty(), ignored -> { updateColors(); this.playAnimation(); }); } protected void updateChildren() { super.updateChildren(); if (this.radio != null) { this.removeRadio(); this.getChildren().add(this.container); } } protected void layoutChildren(double x, double y, double w, double h) { RadioButton radioButton = this.getSkinnable(); double contWidth = this.snapSizeX(this.container.prefWidth(-1.0)) + (double) (this.invalid ? 2 : 0); double contHeight = this.snapSizeY(this.container.prefHeight(-1.0)) + (double) (this.invalid ? 2 : 0); double computeWidth = Math.min(radioButton.prefWidth(-1.0), radioButton.minWidth(-1.0)) + this.labelOffset + 2.0 * this.PADDING; double labelWidth = Math.min(computeWidth - contWidth, w - this.snapSizeX(contWidth)) + this.labelOffset + 2.0 * PADDING; double labelHeight = Math.min(radioButton.prefHeight(labelWidth), h); double maxHeight = Math.max(contHeight, labelHeight); double xOffset = computeXOffset(w, labelWidth + contWidth, radioButton.getAlignment().getHpos()) + x; double yOffset = computeYOffset(h, maxHeight, radioButton.getAlignment().getVpos()) + x; if (this.invalid) { this.initializeComponents(); this.invalid = false; } this.layoutLabelInArea(xOffset + contWidth, yOffset, labelWidth, maxHeight, radioButton.getAlignment()); ((Text) this.getChildren().get(this.getChildren().get(0) instanceof Text ? 0 : 1)).textProperty().set(this.getSkinnable().textProperty().get()); this.container.resize(this.snapSizeX(contWidth), this.snapSizeY(contHeight)); this.positionInArea(this.container, xOffset, yOffset, contWidth, maxHeight, 0.0, radioButton.getAlignment().getHpos(), radioButton.getAlignment().getVpos()); } private void initializeComponents() { this.updateColors(); this.playAnimation(); } private void playAnimation() { if (AnimationUtils.isAnimationEnabled()) { if (this.timeline == null) { this.timeline = new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(this.dot.scaleXProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(this.dot.scaleYProperty(), 0, Interpolator.EASE_BOTH)), new KeyFrame(Duration.millis(200.0), new KeyValue(this.dot.scaleXProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(this.dot.scaleYProperty(), 1, Interpolator.EASE_BOTH)) ); } else { this.timeline.stop(); } this.timeline.setRate(this.getSkinnable().isSelected() ? 1.0 : -1.0); this.timeline.play(); } else { double endScale = this.getSkinnable().isSelected() ? 1.0 : 0.0; this.dot.setScaleX(endScale); this.dot.setScaleY(endScale); } } private void removeRadio() { this.getChildren().removeIf(node -> "radio".equals(node.getStyleClass().get(0))); } private void updateColors() { var control = (JFXRadioButton) getSkinnable(); boolean isSelected = control.isSelected(); Color unSelectedColor = control.getUnSelectedColor(); Color selectedColor = control.getSelectedColor(); rippler.setRipplerFill(isSelected ? selectedColor : unSelectedColor); radio.setStroke(isSelected ? selectedColor : unSelectedColor); } protected double computeMinWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.radio.minWidth(-1.0)) + this.labelOffset + 2.0 * PADDING; } protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { return super.computePrefWidth(height, topInset, rightInset, bottomInset, leftInset) + this.snapSizeX(this.radio.prefWidth(-1.0)) + this.labelOffset + 2.0 * PADDING; } static double computeXOffset(double width, double contentWidth, HPos hpos) { return switch (hpos) { case LEFT -> 0.0; case CENTER -> (width - contentWidth) / 2.0; case RIGHT -> width - contentWidth; }; } static double computeYOffset(double height, double contentHeight, VPos vpos) { return switch (vpos) { case TOP, BASELINE -> 0.0; case CENTER -> (height - contentHeight) / 2.0; case BOTTOM -> height - contentHeight; }; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXSliderSkin.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXSlider; import com.jfoenix.controls.JFXSlider.IndicatorPosition; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.beans.binding.Bindings; import javafx.beans.property.DoubleProperty; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Orientation; import javafx.scene.Node; import javafx.scene.chart.NumberAxis; import javafx.scene.control.Slider; import javafx.scene.control.skin.SliderSkin; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.scene.text.Text; import javafx.util.Duration; /// # Material Design Slider Skin /// /// rework of JFXSliderSkin by extending Java SliderSkin /// this solves padding and resizing issues /// /// @author Shadi Shaheen /// @version 1.0 /// @since 2016-03-09 public class JFXSliderSkin extends SliderSkin { private static final PseudoClass MIN_VALUE = PseudoClass.getPseudoClass("min"); private static final PseudoClass MAX_VALUE = PseudoClass.getPseudoClass("max"); private final Pane mouseHandlerPane = new Pane(); private final Text sliderValue; private final StackPane coloredTrack; private final StackPane thumb; private final StackPane track; private final StackPane animatedThumb; private NumberAxis tickLine; private Timeline timeline; private double indicatorRotation; private double horizontalRotation; private double shifting; public JFXSliderSkin(JFXSlider slider) { super(slider); track = (StackPane) getSkinnable().lookup(".track"); thumb = (StackPane) getSkinnable().lookup(".thumb"); tickLine = (NumberAxis) getSkinnable().lookup(".axis"); if (tickLine != null) tickLine.setAnimated(false); coloredTrack = new StackPane(); coloredTrack.getStyleClass().add("colored-track"); coloredTrack.setMouseTransparent(true); sliderValue = new Text(); sliderValue.getStyleClass().setAll("slider-value"); animatedThumb = new StackPane(); animatedThumb.getStyleClass().add("animated-thumb"); animatedThumb.getChildren().add(sliderValue); animatedThumb.setMouseTransparent(true); animatedThumb.setScaleX(0); animatedThumb.setScaleY(0); thumb.layoutXProperty().addListener(x -> { if (slider.getOrientation() == Orientation.VERTICAL) initAnimation(Orientation.VERTICAL); }); thumb.layoutYProperty().addListener(y -> { if (slider.getOrientation() == Orientation.HORIZONTAL) initAnimation(Orientation.HORIZONTAL); }); addJFXChildren(); getChildren().addListener((ListChangeListener) c -> { while (c.next()) { if (c.wasAdded()) { c.getAddedSubList().forEach(added -> { if (added instanceof NumberAxis) { tickLine = (NumberAxis) added; tickLine.setAnimated(false); } }); } } }); registerChangeListener(slider.showTickMarksProperty(), e -> addJFXChildren()); registerChangeListener(slider.showTickLabelsProperty(), e -> addJFXChildren()); registerChangeListener(slider.valueFactoryProperty(), obs -> refreshSliderValueBinding()); initListeners(); } private void addJFXChildren() { ObservableList children = getChildren(); Slider slider = getSkinnable(); if ((slider.isShowTickMarks() || slider.isShowTickLabels()) && tickLine != null && !children.contains(tickLine)) { children.add(0, tickLine); } if (children.contains(coloredTrack)) return; children.add(children.indexOf(thumb), coloredTrack); children.add(children.indexOf(thumb), animatedThumb); children.add(0, mouseHandlerPane); } private void refreshSliderValueBinding() { sliderValue.textProperty().unbind(); if (((JFXSlider) getSkinnable()).getValueFactory() != null) { sliderValue.textProperty() .bind(((JFXSlider) getSkinnable()).getValueFactory().call((JFXSlider) getSkinnable())); } else { sliderValue.textProperty().bind(Bindings.createStringBinding(() -> { if (getSkinnable().getLabelFormatter() != null) { return getSkinnable().getLabelFormatter().toString(getSkinnable().getValue()); } else { return String.valueOf(Math.round(getSkinnable().getValue())); } }, getSkinnable().valueProperty())); } } @Override protected void layoutChildren(double x, double y, double w, double h) { super.layoutChildren(x, y, w, h); if (timeline == null) { initAnimation(getSkinnable().getOrientation()); } double prefWidth = animatedThumb.prefWidth(-1); animatedThumb.resize(prefWidth, animatedThumb.prefHeight(prefWidth)); boolean horizontal = getSkinnable().getOrientation() == Orientation.HORIZONTAL; double width, height, layoutX, layoutY; if (horizontal) { width = thumb.getLayoutX() - snappedLeftInset(); height = track.getHeight(); layoutX = track.getLayoutX(); layoutY = track.getLayoutY(); animatedThumb.setLayoutX(thumb.getLayoutX() + thumb.getWidth() / 2 - animatedThumb.getWidth() / 2); } else { height = track.getLayoutBounds().getMaxY() + track.getLayoutY() - thumb.getLayoutY() - snappedBottomInset(); width = track.getWidth(); layoutX = track.getLayoutX(); layoutY = thumb.getLayoutY(); animatedThumb.setLayoutY(thumb.getLayoutY() + thumb.getHeight() / 2 - animatedThumb.getHeight() / 2); } coloredTrack.resizeRelocate(layoutX, layoutY, width, height); mouseHandlerPane.resizeRelocate(x, y, w, h); } private void initializeVariables() { shifting = 30 + thumb.getWidth(); if (getSkinnable().getOrientation() != Orientation.HORIZONTAL) { horizontalRotation = -90; } if (((JFXSlider) getSkinnable()).getIndicatorPosition() != IndicatorPosition.LEFT) { indicatorRotation = 180; shifting = -shifting; } final double rotationAngle = 45; sliderValue.setRotate(rotationAngle + indicatorRotation + 3 * horizontalRotation); animatedThumb.setRotate(-rotationAngle + indicatorRotation + horizontalRotation); } private void initListeners() { // delegate slider mouse events to track node mouseHandlerPane.setOnMousePressed(this::delegateToTrack); mouseHandlerPane.setOnMouseReleased(this::delegateToTrack); mouseHandlerPane.setOnMouseDragged(this::delegateToTrack); // animate value node track.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> { timeline.setRate(1); timeline.play(); }); track.addEventHandler(MouseEvent.MOUSE_RELEASED, (event) -> { timeline.setRate(-1); timeline.play(); }); thumb.addEventHandler(MouseEvent.MOUSE_PRESSED, (event) -> { timeline.setRate(1); timeline.play(); }); thumb.addEventHandler(MouseEvent.MOUSE_RELEASED, (event) -> { timeline.setRate(-1); timeline.play(); }); refreshSliderValueBinding(); updateValueStyleClass(); getSkinnable().valueProperty().addListener(observable -> updateValueStyleClass()); } private void delegateToTrack(MouseEvent event) { if (!event.isConsumed()) { event.consume(); track.fireEvent(event); } } private void updateValueStyleClass() { getSkinnable().pseudoClassStateChanged(MIN_VALUE, getSkinnable().getMin() == getSkinnable().getValue()); getSkinnable().pseudoClassStateChanged(MAX_VALUE, getSkinnable().getMax() == getSkinnable().getValue()); } private void initAnimation(Orientation orientation) { initializeVariables(); double thumbPos, thumbNewPos; DoubleProperty layoutProperty; if (orientation == Orientation.HORIZONTAL) { if (((JFXSlider) getSkinnable()).getIndicatorPosition() == IndicatorPosition.RIGHT) { thumbPos = thumb.getLayoutY() - thumb.getHeight(); thumbNewPos = thumbPos - shifting; } else { double height = animatedThumb.prefHeight(animatedThumb.prefWidth(-1)); thumbPos = thumb.getLayoutY() - height / 2; thumbNewPos = thumb.getLayoutY() - height - thumb.getHeight(); } layoutProperty = animatedThumb.translateYProperty(); } else { if (((JFXSlider) getSkinnable()).getIndicatorPosition() == IndicatorPosition.RIGHT) { thumbPos = thumb.getLayoutX() - thumb.getWidth(); thumbNewPos = thumbPos - shifting; } else { double width = animatedThumb.prefWidth(-1); thumbPos = thumb.getLayoutX() - width / 2; thumbNewPos = thumb.getLayoutX() - width - thumb.getWidth(); } layoutProperty = animatedThumb.translateXProperty(); } clearAnimation(); timeline = new Timeline( new KeyFrame( Duration.ZERO, new KeyValue(animatedThumb.scaleXProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(animatedThumb.scaleYProperty(), 0, Interpolator.EASE_BOTH), new KeyValue(layoutProperty, thumbPos, Interpolator.EASE_BOTH)), new KeyFrame( Duration.seconds(0.2), new KeyValue(animatedThumb.scaleXProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(animatedThumb.scaleYProperty(), 1, Interpolator.EASE_BOTH), new KeyValue(layoutProperty, thumbNewPos, Interpolator.EASE_BOTH))); } @Override public void dispose() { super.dispose(); clearAnimation(); } private void clearAnimation() { if (timeline != null) { timeline.stop(); timeline.getKeyFrames().clear(); timeline = null; } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXSpinnerSkin.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXSpinner; import com.jfoenix.utils.TreeShowingProperty; import javafx.animation.Interpolator; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.scene.Group; import javafx.scene.control.SkinBase; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Arc; import javafx.scene.shape.Rectangle; import javafx.scene.shape.StrokeLineCap; import javafx.util.Duration; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import java.util.ArrayList; import java.util.List; /// JFXSpinner material design skin /// /// @author Shadi Shaheen & Gerard Moubarak /// @version 1.0 /// @since 2017-09-25 public class JFXSpinnerSkin extends SkinBase { private static final double DEFAULT_STROKE_WIDTH = 4; private JFXSpinner control; private final TreeShowingProperty treeShowingProperty; private boolean isValid = false; private Timeline timeline; private Arc arc; private Arc track; private final StackPane arcPane; private final Rectangle fillRect; private final double startingAngle; public JFXSpinnerSkin(JFXSpinner control) { super(control); this.control = control; this.treeShowingProperty = new TreeShowingProperty(control); this.startingAngle = control.getStartingAngle(); arc = new Arc(); arc.setManaged(false); arc.setLength(180); arc.getStyleClass().setAll("arc"); arc.setFill(Color.TRANSPARENT); arc.setStrokeWidth(DEFAULT_STROKE_WIDTH); arc.setStrokeLineCap(StrokeLineCap.ROUND); track = new Arc(); track.setManaged(false); track.setLength(360); track.setStrokeWidth(DEFAULT_STROKE_WIDTH); track.getStyleClass().setAll("track"); track.setFill(Color.TRANSPARENT); fillRect = new Rectangle(); fillRect.setFill(Color.TRANSPARENT); final Group group = new Group(fillRect, track, arc); group.setManaged(false); arcPane = new StackPane(group); arcPane.setPrefSize(50, 50); getChildren().setAll(arcPane); // register listeners registerChangeListener(control.progressProperty(), obs -> updateProgress()); registerChangeListener(treeShowingProperty, obs -> updateProgress()); } private void updateProgress() { double progress = Double.min(getSkinnable().getProgress(), 1.0); if (progress < 0) { // indeterminate boolean treeShowing = treeShowingProperty.get(); if (treeShowing) { if (timeline == null) { timeline = createTransition(); timeline.playFromStart(); } else { timeline.play(); } } else if (timeline != null) { timeline.pause(); } } else { // determinate clearAnimation(); arc.setStartAngle(90); arc.setLength(-360 * progress); } } private double computeSize() { return control.getRadius() * 2 + arc.getStrokeWidth() * 2; } @Override protected double computeMaxHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { if (Region.USE_COMPUTED_SIZE == control.getRadius()) { return super.computeMaxHeight(width, topInset, rightInset, bottomInset, leftInset); } else { return computeSize(); } } @Override protected double computeMaxWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { if (Region.USE_COMPUTED_SIZE == control.getRadius()) { return super.computeMaxWidth(height, topInset, rightInset, bottomInset, leftInset); } else { return computeSize(); } } @Override protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { if (Region.USE_COMPUTED_SIZE == control.getRadius()) { return arcPane.prefWidth(-1); } else { return computeSize(); } } @Override protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { if (Region.USE_COMPUTED_SIZE == control.getRadius()) { return arcPane.prefHeight(-1); } else { return computeSize(); } } /** * {@inheritDoc} */ @Override protected void layoutChildren(double contentX, double contentY, double contentWidth, double contentHeight) { final double strokeWidth = arc.getStrokeWidth(); double radius = control.getRadius(); final double arcSize = snapSizeX(radius * 2 + strokeWidth); arcPane.resizeRelocate((contentWidth - arcSize) / 2 + 1, (contentHeight - arcSize) / 2 + 1, arcSize, arcSize); updateArcLayout(radius, arcSize); fillRect.setWidth(arcSize); fillRect.setHeight(arcSize); if (!isValid) { updateProgress(); isValid = true; } } private void updateArcLayout(double radius, double arcSize) { arc.setRadiusX(radius); arc.setRadiusY(radius); arc.setCenterX(arcSize / 2); arc.setCenterY(arcSize / 2); track.setRadiusX(radius); track.setRadiusY(radius); track.setCenterX(arcSize / 2); track.setCenterY(arcSize / 2); track.setStrokeWidth(arc.getStrokeWidth()); } private void addKeyFrames(List frames, double angle, double duration) { frames.add(new KeyFrame(Duration.seconds(duration), new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR), new KeyValue(arc.startAngleProperty(), angle + 45 + startingAngle, Interpolator.LINEAR))); frames.add(new KeyFrame(Duration.seconds(duration + 0.4), new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR), new KeyValue(arc.startAngleProperty(), angle + 90 + startingAngle, Interpolator.LINEAR))); frames.add(new KeyFrame(Duration.seconds(duration + 0.7), new KeyValue(arc.lengthProperty(), 250, Interpolator.LINEAR), new KeyValue(arc.startAngleProperty(), angle + 135 + startingAngle, Interpolator.LINEAR))); frames.add(new KeyFrame(Duration.seconds(duration + 1.1), new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR), new KeyValue(arc.startAngleProperty(), angle + 435 + startingAngle, Interpolator.LINEAR))); } private Timeline createTransition() { Timeline timeline; if (AnimationUtils.isAnimationEnabled()) { var keyFrames = new ArrayList(17); addKeyFrames(keyFrames, 0, 0); addKeyFrames(keyFrames, 450, 1.4); addKeyFrames(keyFrames, 900, 2.8); addKeyFrames(keyFrames, 1350, 4.2); keyFrames.add(new KeyFrame(Duration.seconds(5.6), new KeyValue(arc.lengthProperty(), 5, Interpolator.LINEAR), new KeyValue(arc.startAngleProperty(), 1845 + startingAngle, Interpolator.LINEAR))); timeline = new Timeline(); timeline.getKeyFrames().setAll(keyFrames); } else { final double arcLength = 250; timeline = new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(arc.startAngleProperty(), 45 + startingAngle, Interpolator.LINEAR), new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)), new KeyFrame(Duration.seconds(1.2), new KeyValue(arc.startAngleProperty(), 45 + 360 + startingAngle, Interpolator.LINEAR), new KeyValue(arc.lengthProperty(), arcLength, Interpolator.DISCRETE)) ); } timeline.setCycleCount(Timeline.INDEFINITE); return timeline; } private void clearAnimation() { if (timeline != null) { timeline.stop(); timeline = null; } } @Override public void dispose() { super.dispose(); treeShowingProperty.dispose(); clearAnimation(); arc = null; track = null; control = null; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXTabPaneSkin.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXRippler; import com.jfoenix.controls.JFXRippler.RipplerMask; import com.jfoenix.controls.JFXRippler.RipplerPos; import com.jfoenix.effects.JFXDepthManager; import com.jfoenix.event.MultiplePropertyChangeListenerHandler; import com.jfoenix.svg.SVGGlyph; import com.jfoenix.transitions.CachedTransition; import javafx.animation.*; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.beans.property.DoubleProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ListChangeListener.Change; import javafx.collections.ObservableList; import javafx.collections.WeakListChangeListener; import javafx.css.PseudoClass; import javafx.geometry.HPos; import javafx.geometry.Insets; import javafx.geometry.Side; import javafx.geometry.VPos; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.control.skin.TabPaneSkin; import javafx.scene.input.ContextMenuEvent; import javafx.scene.input.MouseButton; import javafx.scene.input.ScrollEvent; import javafx.scene.layout.*; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.scene.text.Font; import javafx.scene.text.FontWeight; import javafx.scene.transform.Rotate; import javafx.scene.transform.Scale; import javafx.util.Duration; import java.util.ArrayList; import java.util.Iterator; import java.util.List; /** *

Material Design TabPane Skin

* * @author Shadi Shaheen */ public class JFXTabPaneSkin extends TabPaneSkin { private final Color defaultColor = Color.valueOf("#00BCD4"); private final Color ripplerColor = Color.valueOf("#FFFF8D"); private final Color selectedTabText = Color.WHITE; private Color tempLabelColor = Color.WHITE; private HeaderContainer header; private ObservableList tabContentHolders; private Rectangle clip; private Rectangle tabsClip; private Tab selectedTab; private boolean isSelectingTab = false; private double dragStart, offsetStart; private AnchorPane tabsContainer; private AnchorPane tabsContainerHolder; private static final int SPACER = 10; private double maxWidth = 0.0d; private double maxHeight = 0.0d; public JFXTabPaneSkin(TabPane tabPane) { super(tabPane); tabContentHolders = FXCollections.observableArrayList(); header = new HeaderContainer(); getChildren().add(JFXDepthManager.createMaterialNode(header, 1)); tabsContainer = new AnchorPane(); tabsContainerHolder = new AnchorPane(); tabsContainerHolder.getChildren().add(tabsContainer); tabsClip = new Rectangle(); tabsContainerHolder.setClip(tabsClip); getChildren().add(tabsContainerHolder); // add tabs for (Tab tab : getSkinnable().getTabs()) { addTabContentHolder(tab); } // clipping tabpane/header pane clip = new Rectangle(tabPane.getWidth(), tabPane.getHeight()); getSkinnable().setClip(clip); if (getSkinnable().getTabs().size() == 0) { header.setVisible(false); } // select a tab selectedTab = getSkinnable().getSelectionModel().getSelectedItem(); if (selectedTab == null && getSkinnable().getSelectionModel().getSelectedIndex() != -1) { getSkinnable().getSelectionModel().select(getSkinnable().getSelectionModel().getSelectedIndex()); selectedTab = getSkinnable().getSelectionModel().getSelectedItem(); } // if no selected tab, then select the first tab if (selectedTab == null) { getSkinnable().getSelectionModel().selectFirst(); } selectedTab = getSkinnable().getSelectionModel().getSelectedItem(); header.headersRegion.setOnMouseDragged(me -> { header.updateScrollOffset(offsetStart + (isHorizontal() ? me.getSceneX() : me.getSceneY()) - dragStart); me.consume(); }); getSkinnable().setOnMousePressed(me -> { dragStart = (isHorizontal() ? me.getSceneX() : me.getSceneY()); offsetStart = header.scrollOffset; }); // add listeners on tab list getSkinnable().getTabs().addListener((ListChangeListener) change -> { List tabsToBeRemoved = new ArrayList<>(); List tabsToBeAdded = new ArrayList<>(); int insertIndex = -1; while (change.next()) { if (change.wasPermutated()) { Tab selectedTab = getSkinnable().getSelectionModel().getSelectedItem(); List permutatedTabs = new ArrayList<>(change.getTo() - change.getFrom()); getSkinnable().getSelectionModel().clearSelection(); for (int i = change.getFrom(); i < change.getTo(); i++) { permutatedTabs.add(getSkinnable().getTabs().get(i)); } removeTabs(permutatedTabs); addTabs(permutatedTabs, change.getFrom()); getSkinnable().getSelectionModel().select(selectedTab); } if (change.wasRemoved()) { tabsToBeRemoved.addAll(change.getRemoved()); } if (change.wasAdded()) { tabsToBeAdded.addAll(change.getAddedSubList()); insertIndex = change.getFrom(); } } // only remove the tabs that are not in tabsToBeAdded tabsToBeRemoved.removeAll(tabsToBeAdded); removeTabs(tabsToBeRemoved); // add the new tabs if (!tabsToBeAdded.isEmpty()) { for (TabContentHolder tabContentHolder : tabContentHolders) { TabHeaderContainer tabHeaderContainer = header.getTabHeaderContainer(tabContentHolder.tab); if (!tabHeaderContainer.isClosing && tabsToBeAdded.contains(tabContentHolder.tab)) { tabsToBeAdded.remove(tabContentHolder.tab); } } addTabs(tabsToBeAdded, insertIndex == -1 ? tabContentHolders.size() : insertIndex); } getSkinnable().requestLayout(); }); registerChangeListener(tabPane.getSelectionModel().selectedItemProperty(), (e) -> handleControlPropertyChanged("SELECTED_TAB")); registerChangeListener(tabPane.widthProperty(), (e) -> handleControlPropertyChanged("WIDTH")); registerChangeListener(tabPane.heightProperty(), (e) -> handleControlPropertyChanged("HEIGHT")); } protected void handleControlPropertyChanged(String property) { if ("SELECTED_TAB".equals(property)) { isSelectingTab = true; selectedTab = getSkinnable().getSelectionModel().getSelectedItem(); getSkinnable().requestLayout(); } else if ("WIDTH".equals(property)) { clip.setWidth(getSkinnable().getWidth()); } else if ("HEIGHT".equals(property)) { clip.setHeight(getSkinnable().getHeight()); } } private void removeTabs(List removedTabs) { for (Tab tab : removedTabs) { TabHeaderContainer tabHeaderContainer = header.getTabHeaderContainer(tab); if (tabHeaderContainer != null) { tabHeaderContainer.isClosing = true; removeTab(tab); // if tabs list is empty hide the header container if (getSkinnable().getTabs().isEmpty()) { header.setVisible(false); } } } } private void addTabs(List addedTabs, int startIndex) { int i = 0; for (Tab tab : addedTabs) { // show header container if we are adding the 1st tab if (!header.isVisible()) { header.setVisible(true); } header.addTab(tab, startIndex + i++, false); addTabContentHolder(tab); final TabHeaderContainer tabHeaderContainer = header.getTabHeaderContainer(tab); if (tabHeaderContainer != null) { tabHeaderContainer.setVisible(true); tabHeaderContainer.inner.requestLayout(); } } } private void addTabContentHolder(Tab tab) { // create new content place holder TabContentHolder tabContentHolder = new TabContentHolder(tab); tabContentHolder.setClip(new Rectangle()); tabContentHolders.add(tabContentHolder); // always add tab content holder below its header tabsContainer.getChildren().add(0, tabContentHolder); } private void removeTabContentHolder(Tab tab) { for (TabContentHolder tabContentHolder : tabContentHolders) { if (tabContentHolder.tab.equals(tab)) { tabContentHolder.removeListeners(tab); getChildren().remove(tabContentHolder); tabContentHolders.remove(tabContentHolder); tabsContainer.getChildren().remove(tabContentHolder); break; } } } private void removeTab(Tab tab) { final TabHeaderContainer tabHeaderContainer = header.getTabHeaderContainer(tab); if (tabHeaderContainer != null) { tabHeaderContainer.removeListeners(tab); } header.removeTab(tab); removeTabContentHolder(tab); header.requestLayout(); } private boolean isHorizontal() { final Side tabPosition = getSkinnable().getSide(); return Side.TOP.equals(tabPosition) || Side.BOTTOM.equals(tabPosition); } private static int getRotation(Side pos) { switch (pos) { case TOP: return 0; case BOTTOM: return 180; case LEFT: return -90; case RIGHT: return 90; default: return 0; } } @Override protected double computePrefWidth(double height, double topInset, double rightInset, double bottomInset, double leftInset) { for (TabContentHolder tabContentHolder : tabContentHolders) { maxWidth = Math.max(maxWidth, snapSize(tabContentHolder.prefWidth(-1))); } final double headerContainerWidth = snapSize(header.prefWidth(-1)); double prefWidth = Math.max(maxWidth, headerContainerWidth); return snapSize(prefWidth) + rightInset + leftInset; } @Override protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { for (TabContentHolder tabContentHolder : tabContentHolders) { maxHeight = Math.max(maxHeight, snapSize(tabContentHolder.prefHeight(-1))); } final double headerContainerHeight = snapSize(header.prefHeight(-1)); double prefHeight = maxHeight + snapSize(headerContainerHeight); return snapSize(prefHeight) + topInset + bottomInset; } @Override public double computeBaselineOffset(double topInset, double rightInset, double bottomInset, double leftInset) { return header.getBaselineOffset() + topInset; } /* * keep track of indices after changing the tabs, it used to fix * tabs animation after changing the tabs (remove/add) */ private int diffTabsIndices = 0; @Override protected void layoutChildren(final double x, final double y, final double w, final double h) { final double headerHeight = snapSize(header.prefHeight(-1)); final Side side = getSkinnable().getSide(); double tabsX = side == Side.RIGHT ? x + w - headerHeight : x; double tabsY = side == Side.BOTTOM ? y + h - headerHeight : y; final int rotation = getRotation(side); // update header switch (side) { case TOP: header.resize(w, headerHeight); header.relocate(tabsX, tabsY); break; case LEFT: header.resize(h, headerHeight); header.relocate(tabsX + headerHeight, h - headerHeight); break; case RIGHT: header.resize(h, headerHeight); header.relocate(tabsX, y - headerHeight); break; case BOTTOM: header.resize(w, headerHeight); header.relocate(w, tabsY - headerHeight); break; } header.getTransforms().setAll(new Rotate(rotation, 0, headerHeight, 1)); // update header clip // header.clip.setX(0); // header.clip.setY(0); // header.clip.setWidth(isHorizontal() ? w : h); // header.clip.setHeight(headerHeight + 10); // 10 is the height of the shadow effect // position the tab content of the current selected tab double contentStartX = x + (side == Side.LEFT ? headerHeight : 0); double contentStartY = y + (side == Side.TOP ? headerHeight : 0); double contentWidth = w - (isHorizontal() ? 0 : headerHeight); double contentHeight = h - (isHorizontal() ? headerHeight : 0); // update tabs container tabsClip.setWidth(contentWidth); tabsClip.setHeight(contentHeight); tabsContainerHolder.resize(contentWidth, contentHeight); tabsContainerHolder.relocate(contentStartX, contentStartY); tabsContainer.resize(contentWidth * tabContentHolders.size(), contentHeight); for (int i = 0, max = tabContentHolders.size(); i < max; i++) { TabContentHolder tabContentHolder = tabContentHolders.get(i); tabContentHolder.setVisible(true); tabContentHolder.setTranslateX(contentWidth * i); if (tabContentHolder.getClip() != null) { ((Rectangle) tabContentHolder.getClip()).setWidth(contentWidth); ((Rectangle) tabContentHolder.getClip()).setHeight(contentHeight); } if (tabContentHolder.tab == selectedTab) { int index = getSkinnable().getTabs().indexOf(selectedTab); if (index != i) { tabsContainer.setTranslateX(-contentWidth * i); diffTabsIndices = i - index; } else { // fix X translation after changing the tabs if (diffTabsIndices != 0) { tabsContainer.setTranslateX(tabsContainer.getTranslateX() + contentWidth * diffTabsIndices); diffTabsIndices = 0; } // animate upon tab selection only otherwise just translate the selected tab if (isSelectingTab) { new CachedTransition(tabsContainer, new Timeline(new KeyFrame(Duration.millis(1000), new KeyValue(tabsContainer.translateXProperty(), -contentWidth * index, Interpolator.EASE_BOTH)))) {{ setCycleDuration(Duration.seconds(0.320)); setDelay(Duration.seconds(0)); } }.play(); } else { tabsContainer.setTranslateX(-contentWidth * index); } } } tabContentHolder.resize(contentWidth, contentHeight); // tabContentHolder.relocate(contentStartX, contentStartY); } } /************************************************************************** * * * HeaderContainer: tabs headers container * * * **************************************************************************/ protected class HeaderContainer extends StackPane { private Rectangle clip; private StackPane headersRegion; private StackPane headerBackground; private HeaderControl rightControlButton; private HeaderControl leftControlButton; private StackPane selectedTabLine; private boolean initialized = false; private boolean measureClosingTabs = false; private double scrollOffset, selectedTabLineOffset; private final Scale scale; private final Rotate rotate; private int direction; private Timeline timeline; private final double translateScaleFactor = 1.3; public HeaderContainer() { // keep track of the current side getSkinnable().sideProperty().addListener(observable -> updateDirection()); updateDirection(); getStyleClass().setAll("tab-header-area"); setManaged(false); clip = new Rectangle(); headersRegion = new StackPane() { @Override protected double computePrefWidth(double height) { double width = 0.0F; for (Node child : getChildren()) { if (child instanceof TabHeaderContainer && child.isVisible() && (measureClosingTabs || !((TabHeaderContainer) child).isClosing)) { width += child.prefWidth(height); } } return snapSize(width) + snappedLeftInset() + snappedRightInset(); } @Override protected double computePrefHeight(double width) { double height = 0.0F; for (Node child : getChildren()) { if (child instanceof TabHeaderContainer) { height = Math.max(height, child.prefHeight(width)); } } return snapSize(height) + snappedTopInset() + snappedBottomInset(); } @Override protected void layoutChildren() { if (isTabsFitHeaderWidth()) { updateScrollOffset(0.0); } else { if (!removedTabsHeaders.isEmpty()) { double offset = 0; double w = header.getWidth() - snapSize(rightControlButton.prefWidth(-1)) - snapSize(leftControlButton.prefWidth(-1)) - snappedLeftInset() - SPACER; Iterator itr = getChildren().iterator(); while (itr.hasNext()) { Node temp = itr.next(); if (temp instanceof TabHeaderContainer) { TabHeaderContainer tabHeaderContainer = (TabHeaderContainer) temp; double containerPrefWidth = snapSize(tabHeaderContainer.prefWidth(-1)); // if tab has been removed if (removedTabsHeaders.contains(tabHeaderContainer)) { if (offset < w) { isSelectingTab = true; } itr.remove(); removedTabsHeaders.remove(tabHeaderContainer); if (removedTabsHeaders.isEmpty()) { break; } } offset += containerPrefWidth; } } } } if (isSelectingTab) { // make sure the selected tab is visible animateSelectionLine(); isSelectingTab = false; } else { // validate scroll offset updateScrollOffset(scrollOffset); } final double tabBackgroundHeight = snapSize(prefHeight(-1)); final Side side = getSkinnable().getSide(); double tabStartX = (side == Side.LEFT || side == Side.BOTTOM) ? snapSize(getWidth()) - scrollOffset : scrollOffset; updateHeaderContainerClip(); for (Node node : getChildren()) { if (node instanceof TabHeaderContainer) { TabHeaderContainer tabHeaderContainer = (TabHeaderContainer) node; double tabHeaderPrefWidth = snapSize(tabHeaderContainer.prefWidth(-1)); double tabHeaderPrefHeight = snapSize(tabHeaderContainer.prefHeight(-1)); tabHeaderContainer.resize(tabHeaderPrefWidth, tabHeaderPrefHeight); double tabStartY = side == Side.BOTTOM ? 0 : tabBackgroundHeight - tabHeaderPrefHeight - snappedBottomInset(); if (side == Side.LEFT || side == Side.BOTTOM) { // build from the right tabStartX -= tabHeaderPrefWidth; tabHeaderContainer.relocate(tabStartX, tabStartY); } else { // build from the left tabHeaderContainer.relocate(tabStartX, tabStartY); tabStartX += tabHeaderPrefWidth; } } } selectedTabLine.resizeRelocate((side == Side.LEFT || side == Side.BOTTOM) ? snapSize(headersRegion.getWidth()) : 0, tabBackgroundHeight - selectedTabLine.prefHeight(-1), snapSize(selectedTabLine.prefWidth(-1)), snapSize(selectedTabLine.prefHeight(-1))); } }; headersRegion.getStyleClass().setAll("headers-region"); headersRegion.setCache(true); headersRegion.setClip(clip); headerBackground = new StackPane(); headerBackground.setBackground(new Background(new BackgroundFill(defaultColor, CornerRadii.EMPTY, Insets.EMPTY))); headerBackground.getStyleClass().setAll("tab-header-background"); selectedTabLine = new StackPane(); scale = new Scale(1, 1, 0, 0); rotate = new Rotate(0, 0, 1); rotate.pivotYProperty().bind(selectedTabLine.heightProperty().divide(2)); selectedTabLine.getTransforms().addAll(scale, rotate); selectedTabLine.setCache(true); selectedTabLine.getStyleClass().add("tab-selected-line"); selectedTabLine.setPrefHeight(2); selectedTabLine.setPrefWidth(1); selectedTabLine.setBackground(new Background(new BackgroundFill(ripplerColor, CornerRadii.EMPTY, Insets.EMPTY))); headersRegion.getChildren().add(selectedTabLine); rightControlButton = new HeaderControl(ArrowPosition.RIGHT); leftControlButton = new HeaderControl(ArrowPosition.LEFT); rightControlButton.setVisible(false); leftControlButton.setVisible(false); rightControlButton.inner.prefHeightProperty().bind(headersRegion.heightProperty()); leftControlButton.inner.prefHeightProperty().bind(headersRegion.heightProperty()); getChildren().addAll(headerBackground, headersRegion, leftControlButton, rightControlButton); int i = 0; for (Tab tab : getSkinnable().getTabs()) { addTab(tab, i++, true); } // support for mouse scroll of header area addEventHandler(ScrollEvent.SCROLL, (ScrollEvent e) -> updateScrollOffset(scrollOffset + e.getDeltaY() * (isHorizontal() ? -1 : 1))); } private void updateDirection() { final Side side = getSkinnable().getSide(); direction = (side == Side.BOTTOM || side == Side.LEFT) ? -1 : 1; } private void updateHeaderContainerClip() { final double clipOffset = getClipOffset(); final Side side = getSkinnable().getSide(); double controlPrefWidth = 2 * snapSize(rightControlButton.prefWidth(-1)); // Add the spacer if the control buttons are shown // controlPrefWidth = controlPrefWidth > 0 ? controlPrefWidth + SPACER : controlPrefWidth; measureClosingTabs = true; final double headersPrefWidth = snapSize(headersRegion.prefWidth(-1)); final double headersPrefHeight = snapSize(headersRegion.prefHeight(-1)); measureClosingTabs = false; final double maxWidth = snapSize(getWidth()) - controlPrefWidth - clipOffset; final double clipWidth = headersPrefWidth < maxWidth ? headersPrefWidth : maxWidth; final double clipHeight = headersPrefHeight; clip.setX((side == Side.LEFT || side == Side.BOTTOM) && headersPrefWidth >= maxWidth ? headersPrefWidth - maxWidth : 0); clip.setY(0); clip.setWidth(clipWidth); clip.setHeight(clipHeight); } private double getClipOffset() { return isHorizontal() ? snappedLeftInset() : snappedRightInset(); } private void addTab(Tab tab, int addToIndex, boolean visible) { TabHeaderContainer tabHeaderContainer = new TabHeaderContainer(tab); tabHeaderContainer.setVisible(visible); headersRegion.getChildren().add(addToIndex, tabHeaderContainer); } private List removedTabsHeaders = new ArrayList<>(); private void removeTab(Tab tab) { TabHeaderContainer tabHeaderContainer = getTabHeaderContainer(tab); if (tabHeaderContainer != null) { if (isTabsFitHeaderWidth()) { headersRegion.getChildren().remove(tabHeaderContainer); } else { // we need to keep track of the removed tab headers // to compute scroll offset of the header removedTabsHeaders.add(tabHeaderContainer); tabHeaderContainer.removeListeners(tab); } } } private TabHeaderContainer getTabHeaderContainer(Tab tab) { for (Node child : headersRegion.getChildren()) { if (child instanceof TabHeaderContainer) { if (((TabHeaderContainer) child).tab.equals(tab)) { return (TabHeaderContainer) child; } } } return null; } private boolean isTabsFitHeaderWidth() { double headerPrefWidth = snapSize(headersRegion.prefWidth(-1)); double rightControlWidth = 2 * snapSize(rightControlButton.prefWidth(-1)); double visibleWidth = headerPrefWidth + rightControlWidth + snappedLeftInset() + SPACER; return visibleWidth < getWidth(); } private void runTimeline(double newTransX, double newWidth) { newWidth = snapSizeX(newWidth); newTransX = snapPositionX(newTransX); double tempScaleX = 0; double tempWidth = 0; final double lineWidth = snapSizeX(selectedTabLine.prefWidth(-1)); if (isAnimating()) { timeline.stop(); tempScaleX = scale.getX(); if (rotate.getAngle() != 0) { rotate.setAngle(0); tempWidth = snapSizeX(tempScaleX * lineWidth); selectedTabLine.setTranslateX(snapPositionX(selectedTabLine.getTranslateX() - tempWidth)); } } final double oldScaleX = scale.getX(); final double oldWidth = snapSizeX(lineWidth * oldScaleX); final double oldTransX = snapPositionX(selectedTabLine.getTranslateX()); final double newScaleX = newWidth / lineWidth; selectedTabLineOffset = newTransX; newTransX = snapPositionX(newTransX + offsetStart * direction); final double transDiff = newTransX - oldTransX; double midScaleX = tempScaleX != 0 ? tempScaleX : snapSizeX(((Math.abs(transDiff) / translateScaleFactor + oldWidth)) / lineWidth); if (transDiff < 0) { selectedTabLine.setTranslateX(snapPositionX(selectedTabLine.getTranslateX() + oldWidth)); newTransX = snapPositionX(newTransX + newWidth); rotate.setAngle(180); } timeline = new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(selectedTabLine.translateXProperty(), selectedTabLine.getTranslateX(), Interpolator.EASE_BOTH)), new KeyFrame(Duration.seconds(0.12), new KeyValue(scale.xProperty(), midScaleX, Interpolator.EASE_BOTH)), new KeyFrame(Duration.seconds(0.24), new KeyValue(scale.xProperty(), newScaleX, Interpolator.EASE_BOTH), new KeyValue(selectedTabLine.translateXProperty(), newTransX, Interpolator.EASE_BOTH))); timeline.setOnFinished(finish -> { if (rotate.getAngle() != 0) { rotate.setAngle(0); double finalX = snapPositionX(selectedTabLine.getTranslateX() - (lineWidth * newScaleX)); selectedTabLine.setTranslateX(finalX); } }); timeline.play(); } private boolean isAnimating() { return timeline != null && timeline.getStatus() == Animation.Status.RUNNING; } public void updateScrollOffset(double newOffset) { double tabPaneWidth = snapSize(isHorizontal() ? getSkinnable().getWidth() : getSkinnable().getHeight()); double controlTabWidth = 2 * snapSize(rightControlButton.getWidth()); double visibleWidth = tabPaneWidth - controlTabWidth - snappedLeftInset() - SPACER; // compute all tabs headers width double offset = 0.0; for (Node node : headersRegion.getChildren()) { if (node instanceof TabHeaderContainer) { double tabHeaderPrefWidth = snapSize(node.prefWidth(-1)); offset += tabHeaderPrefWidth; } } double actualOffset = newOffset; if ((visibleWidth - newOffset) > offset && newOffset < 0) { actualOffset = visibleWidth - offset; } else if (newOffset > 0) { actualOffset = 0; } if (actualOffset != scrollOffset) { scrollOffset = actualOffset; headersRegion.requestLayout(); if (!isAnimating()) { selectedTabLine.setTranslateX(selectedTabLineOffset + scrollOffset * direction); } } } @Override protected double computePrefWidth(double height) { final double padding = isHorizontal() ? 2 * snappedLeftInset() + snappedRightInset() : 2 * snappedTopInset() + snappedBottomInset(); return snapSize(headersRegion.prefWidth(height)) + 2 * rightControlButton.prefWidth(height) + padding + SPACER; } @Override protected double computePrefHeight(double width) { final double padding = isHorizontal() ? snappedTopInset() + snappedBottomInset() : snappedLeftInset() + snappedRightInset(); return snapSize(headersRegion.prefHeight(-1)) + padding; } @Override public double getBaselineOffset() { return getSkinnable().getSide() == Side.TOP ? headersRegion.getBaselineOffset() + snappedTopInset() : 0; } @Override protected void layoutChildren() { final double leftInset = snappedLeftInset(); final double rightInset = snappedRightInset(); final double topInset = snappedTopInset(); final double bottomInset = snappedBottomInset(); final double padding = isHorizontal() ? leftInset + rightInset : topInset + bottomInset; final double w = snapSize(getWidth()) - padding; final double h = snapSize(getHeight()) - padding; final double tabBackgroundHeight = snapSize(prefHeight(-1)); final double headersPrefWidth = snapSize(headersRegion.prefWidth(-1)); final double headersPrefHeight = snapSize(headersRegion.prefHeight(-1)); rightControlButton.showTabsMenu(!isTabsFitHeaderWidth()); leftControlButton.showTabsMenu(!isTabsFitHeaderWidth()); updateHeaderContainerClip(); headersRegion.requestLayout(); // layout left/right controls buttons final double btnWidth = snapSize(rightControlButton.prefWidth(-1)); final double btnHeight = rightControlButton.prefHeight(btnWidth); rightControlButton.resize(btnWidth, btnHeight); leftControlButton.resize(btnWidth, btnHeight); // layout tabs headersRegion.resize(headersPrefWidth, headersPrefHeight); headerBackground.resize(snapSize(getWidth()), snapSize(getHeight())); final Side side = getSkinnable().getSide(); double startX = 0; double startY = 0; double controlStartX = 0; double controlStartY = 0; switch (side) { case TOP: startX = leftInset; startY = tabBackgroundHeight - headersPrefHeight - bottomInset; controlStartX = w - btnWidth + leftInset; controlStartY = snapSize(getHeight()) - btnHeight - bottomInset; break; case BOTTOM: startX = snapSize(getWidth()) - headersPrefWidth - leftInset; startY = tabBackgroundHeight - headersPrefHeight - topInset; controlStartX = rightInset; controlStartY = snapSize(getHeight()) - btnHeight - topInset; break; case LEFT: startX = snapSize(getWidth()) - headersPrefWidth - topInset; startY = tabBackgroundHeight - headersPrefHeight - rightInset; controlStartX = leftInset; controlStartY = snapSize(getHeight()) - btnHeight - rightInset; break; case RIGHT: startX = topInset; startY = tabBackgroundHeight - headersPrefHeight - leftInset; controlStartX = w - btnWidth + topInset; controlStartY = snapSize(getHeight()) - btnHeight - leftInset; break; } if (headerBackground.isVisible()) { positionInArea(headerBackground, 0, 0, snapSize(getWidth()), snapSize(getHeight()), 0, HPos.CENTER, VPos.CENTER); } positionInArea(headersRegion, startX + btnWidth * ((side == Side.LEFT || side == Side.BOTTOM) ? -1 : 1), startY, w, h, 0, HPos.LEFT, VPos.CENTER); positionInArea(rightControlButton, controlStartX, controlStartY, btnWidth, btnHeight, 0, HPos.CENTER, VPos.CENTER); positionInArea(leftControlButton, (side == Side.LEFT || side == Side.BOTTOM) ? w - btnWidth : 0, controlStartY, btnWidth, btnHeight, 0, HPos.CENTER, VPos.CENTER); rightControlButton.setRotate((side == Side.LEFT || side == Side.BOTTOM) ? 180.0F : 0.0F); leftControlButton.setRotate((side == Side.LEFT || side == Side.BOTTOM) ? 180.0F : 0.0F); if (!initialized) { animateSelectionLine(); initialized = true; } } private void animateSelectionLine() { double offset = 0.0; double selectedTabOffset = 0.0; double selectedTabWidth = 0.0; final Side side = getSkinnable().getSide(); for (Node node : headersRegion.getChildren()) { if (node instanceof TabHeaderContainer) { TabHeaderContainer tabHeader = (TabHeaderContainer) node; double tabHeaderPrefWidth = snapSize(tabHeader.prefWidth(-1)); if (selectedTab != null && selectedTab.equals(tabHeader.tab)) { selectedTabOffset = (side == Side.LEFT || side == Side.BOTTOM) ? -offset - tabHeaderPrefWidth : offset; selectedTabWidth = tabHeaderPrefWidth; break; } offset += tabHeaderPrefWidth; } } // animate the tab selection runTimeline(selectedTabOffset, selectedTabWidth); } } /************************************************************************** * * * TabHeaderContainer: each tab Container * * * **************************************************************************/ protected class TabHeaderContainer extends StackPane { private Tab tab = null; private Label tabText; private Tooltip oldTooltip; private Tooltip tooltip; private BorderPane inner; private JFXRippler rippler; private boolean systemChange = false; private boolean isClosing = false; private final MultiplePropertyChangeListenerHandler listener = new MultiplePropertyChangeListenerHandler(param -> { handlePropertyChanged(param); return null; }); private final ListChangeListener styleClassListener = (Change change) -> getStyleClass().setAll(tab.getStyleClass()); private final WeakListChangeListener weakStyleClassListener = new WeakListChangeListener<>(styleClassListener); public TabHeaderContainer(final Tab tab) { this.tab = tab; getStyleClass().setAll(tab.getStyleClass()); setId(tab.getId()); setStyle(tab.getStyle()); tabText = new Label(tab.getText(), tab.getGraphic()); tabText.setFont(Font.font("", FontWeight.BOLD, 16)); tabText.setPadding(new Insets(5, 10, 5, 10)); tabText.getStyleClass().setAll("tab-label"); inner = new BorderPane(); inner.setCenter(tabText); inner.getStyleClass().add("tab-container"); inner.setRotate(getSkinnable().getSide().equals(Side.BOTTOM) ? 180.0F : 0.0F); rippler = new JFXRippler(inner, RipplerPos.FRONT); rippler.setRipplerFill(ripplerColor); getChildren().addAll(rippler); tooltip = tab.getTooltip(); if (tooltip != null) { Tooltip.install(this, tooltip); oldTooltip = tooltip; } if (tab.isSelected()) { tabText.setTextFill(selectedTabText); } else { tabText.setTextFill(tempLabelColor.deriveColor(0, 0, 0.9, 1)); } tabText.textFillProperty().addListener((o, oldVal, newVal) -> { if (!systemChange) { tempLabelColor = (Color) newVal; } }); tab.selectedProperty().addListener((o, oldVal, newVal) -> { systemChange = true; if (newVal) { tabText.setTextFill(tempLabelColor); } else { tabText.setTextFill(tempLabelColor.deriveColor(0, 0, 0.9, 1)); } systemChange = false; }); listener.registerChangeListener(tab.selectedProperty(), "SELECTED"); listener.registerChangeListener(tab.textProperty(), "TEXT"); listener.registerChangeListener(tab.graphicProperty(), "GRAPHIC"); listener.registerChangeListener(tab.tooltipProperty(), "TOOLTIP"); listener.registerChangeListener(tab.disableProperty(), "DISABLE"); listener.registerChangeListener(tab.styleProperty(), "STYLE"); listener.registerChangeListener(getSkinnable().tabMinWidthProperty(), "TAB_MIN_WIDTH"); listener.registerChangeListener(getSkinnable().tabMaxWidthProperty(), "TAB_MAX_WIDTH"); listener.registerChangeListener(getSkinnable().tabMinHeightProperty(), "TAB_MIN_HEIGHT"); listener.registerChangeListener(getSkinnable().tabMaxHeightProperty(), "TAB_MAX_HEIGHT"); listener.registerChangeListener(getSkinnable().sideProperty(), "SIDE"); tab.getStyleClass().addListener(weakStyleClassListener); getProperties().put(Tab.class, tab); setOnMouseClicked((event) -> { if (tab.isDisable() || !event.isStillSincePress()) { return; } if (event.getButton() == MouseButton.PRIMARY) { setOpacity(1); TabPane tabPane = tab.getTabPane(); if (tabPane != null) { tabPane.getSelectionModel().select(tab); } } }); addEventHandler(ContextMenuEvent.CONTEXT_MENU_REQUESTED, event -> { ContextMenu contextMenu = tab.getContextMenu(); if (contextMenu != null) { contextMenu.show(tabText, event.getScreenX(), event.getScreenY()); event.consume(); } }); // initialize pseudo-class state pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, tab.isSelected()); pseudoClassStateChanged(DISABLED_PSEUDOCLASS_STATE, tab.isDisable()); final Side side = getSkinnable().getSide(); pseudoClassStateChanged(TOP_PSEUDOCLASS_STATE, (side == Side.TOP)); pseudoClassStateChanged(RIGHT_PSEUDOCLASS_STATE, (side == Side.RIGHT)); pseudoClassStateChanged(BOTTOM_PSEUDOCLASS_STATE, (side == Side.BOTTOM)); pseudoClassStateChanged(LEFT_PSEUDOCLASS_STATE, (side == Side.LEFT)); } private void handlePropertyChanged(final String p) { if ("SELECTED".equals(p)) { pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, tab.isSelected()); inner.requestLayout(); requestLayout(); } else if ("TEXT".equals(p)) { tabText.setText(tab.getText()); } else if ("GRAPHIC".equals(p)) { tabText.setGraphic(tab.getGraphic()); } else if ("TOOLTIP".equals(p)) { // install new Tooltip / uninstall the old one if (oldTooltip != null) { Tooltip.uninstall(this, oldTooltip); } tooltip = tab.getTooltip(); if (tooltip != null) { Tooltip.install(this, tooltip); oldTooltip = tooltip; } } else if ("DISABLE".equals(p)) { pseudoClassStateChanged(DISABLED_PSEUDOCLASS_STATE, tab.isDisable()); inner.requestLayout(); requestLayout(); } else if ("STYLE".equals(p)) { setStyle(tab.getStyle()); } else if ("TAB_MIN_WIDTH".equals(p)) { requestLayout(); getSkinnable().requestLayout(); } else if ("TAB_MAX_WIDTH".equals(p)) { requestLayout(); getSkinnable().requestLayout(); } else if ("TAB_MIN_HEIGHT".equals(p)) { requestLayout(); getSkinnable().requestLayout(); } else if ("TAB_MAX_HEIGHT".equals(p)) { requestLayout(); getSkinnable().requestLayout(); } else if ("SIDE".equals(p)) { final Side side = getSkinnable().getSide(); pseudoClassStateChanged(TOP_PSEUDOCLASS_STATE, (side == Side.TOP)); pseudoClassStateChanged(RIGHT_PSEUDOCLASS_STATE, (side == Side.RIGHT)); pseudoClassStateChanged(BOTTOM_PSEUDOCLASS_STATE, (side == Side.BOTTOM)); pseudoClassStateChanged(LEFT_PSEUDOCLASS_STATE, (side == Side.LEFT)); inner.setRotate(side == Side.BOTTOM ? 180.0F : 0.0F); } } private void removeListeners(Tab tab) { listener.dispose(); inner.getChildren().clear(); getChildren().clear(); } @Override protected double computePrefWidth(double height) { double minWidth = snapSize(getSkinnable().getTabMinWidth()); double maxWidth = snapSize(getSkinnable().getTabMaxWidth()); double paddingRight = snappedRightInset(); double paddingLeft = snappedLeftInset(); double tmpPrefWidth = snapSize(tabText.prefWidth(-1)); if (tmpPrefWidth > maxWidth) { tmpPrefWidth = maxWidth; } else if (tmpPrefWidth < minWidth) { tmpPrefWidth = minWidth; } tmpPrefWidth += paddingRight + paddingLeft; return tmpPrefWidth; } @Override protected double computePrefHeight(double width) { double minHeight = snapSize(getSkinnable().getTabMinHeight()); double maxHeight = snapSize(getSkinnable().getTabMaxHeight()); double paddingTop = snappedTopInset(); double paddingBottom = snappedBottomInset(); double tmpPrefHeight = snapSize(tabText.prefHeight(width)); if (tmpPrefHeight > maxHeight) { tmpPrefHeight = maxHeight; } else if (tmpPrefHeight < minHeight) { tmpPrefHeight = minHeight; } tmpPrefHeight += paddingTop + paddingBottom; return tmpPrefHeight; } @Override protected void layoutChildren() { double w = snapSize(getWidth()) - snappedRightInset() - snappedLeftInset(); rippler.resize(w, snapSize(getHeight()) - snappedTopInset() - snappedBottomInset()); rippler.relocate(snappedLeftInset(), snappedTopInset()); } @Override protected void setWidth(double value) { super.setWidth(value); } @Override protected void setHeight(double value) { super.setHeight(value); } } private static final PseudoClass SELECTED_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("selected"); private static final PseudoClass DISABLED_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("disabled"); private static final PseudoClass TOP_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("top"); private static final PseudoClass BOTTOM_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("bottom"); private static final PseudoClass LEFT_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("left"); private static final PseudoClass RIGHT_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("right"); /************************************************************************** * * * TabContentHolder: each tab content container * * * **************************************************************************/ protected static class TabContentHolder extends StackPane { private Tab tab; private InvalidationListener tabContentListener = valueModel -> updateContent(); private InvalidationListener tabSelectedListener = valueModel -> setVisible(tab.isSelected()); private WeakInvalidationListener weakTabContentListener = new WeakInvalidationListener(tabContentListener); private WeakInvalidationListener weakTabSelectedListener = new WeakInvalidationListener(tabSelectedListener); public TabContentHolder(Tab tab) { this.tab = tab; getStyleClass().setAll("tab-content-area"); setManaged(false); updateContent(); setVisible(tab.isSelected()); tab.selectedProperty().addListener(weakTabSelectedListener); tab.contentProperty().addListener(weakTabContentListener); } private void updateContent() { Node newContent = tab.getContent(); if (newContent == null) { getChildren().clear(); } else { getChildren().setAll(newContent); } } private void removeListeners(Tab tab) { tab.selectedProperty().removeListener(weakTabSelectedListener); tab.contentProperty().removeListener(weakTabContentListener); } } private enum ArrowPosition { RIGHT, LEFT } /************************************************************************** * * * HeaderControl: left/right controls to interact with HeaderContainer* * * **************************************************************************/ protected class HeaderControl extends StackPane { private StackPane inner; private boolean showControlButtons, isLeftArrow; private Timeline arrowAnimation; private SVGGlyph arrowButton; private SVGGlyph leftChevron = new SVGGlyph(0, "CHEVRON_LEFT", "M 742,-37 90,614 Q 53,651 53,704.5 53,758 90,795 l 652,651 q 37,37 90.5,37 53.5,0 90.5,-37 l 75,-75 q 37,-37 37,-90.5 0,-53.5 -37,-90.5 L 512,704 998,219 q 37,-38 37,-91 0,-53 -37,-90 L 923,-37 Q 886,-74 832.5,-74 779,-74 742,-37 z", Color.WHITE); private SVGGlyph rightChevron = new SVGGlyph(0, "CHEVRON_RIGHT", "m 1099,704 q 0,-52 -37,-91 L 410,-38 q -37,-37 -90,-37 -53,0 -90,37 l -76,75 q -37,39 -37,91 0,53 37,90 l 486,486 -486,485 q -37,39 -37,91 0,53 37,90 l 76,75 q 36,38 90,38 54,0 90,-38 l 652,-651 q 37,-37 37,-90 z", Color.WHITE); public HeaderControl(ArrowPosition pos) { getStyleClass().setAll("control-buttons-tab"); isLeftArrow = pos == ArrowPosition.LEFT; arrowButton = isLeftArrow ? leftChevron : rightChevron; arrowButton.setStyle("-fx-min-width:0.8em;-fx-max-width:0.8em;-fx-min-height:1.3em;-fx-max-height:1.3em;"); arrowButton.getStyleClass().setAll("tab-down-button"); arrowButton.setVisible(isControlButtonShown()); arrowButton.setFill(selectedTabText); DoubleProperty offsetProperty = new SimpleDoubleProperty(0); offsetProperty.addListener((o, oldVal, newVal) -> header.updateScrollOffset(newVal.doubleValue())); StackPane container = new StackPane(arrowButton); container.getStyleClass().add("container"); container.setPadding(new Insets(7)); container.setCursor(Cursor.HAND); container.setOnMousePressed(press -> { offsetProperty.set(header.scrollOffset); double offset = isLeftArrow ? header.scrollOffset + header.headersRegion.getWidth() : header.scrollOffset - header.headersRegion.getWidth(); arrowAnimation = new Timeline(new KeyFrame(Duration.seconds(1), new KeyValue(offsetProperty, offset, Interpolator.LINEAR))); arrowAnimation.play(); }); container.setOnMouseReleased(release -> arrowAnimation.stop()); JFXRippler arrowRippler = new JFXRippler(container, RipplerMask.CIRCLE, RipplerPos.BACK); arrowRippler.ripplerFillProperty().bind(arrowButton.fillProperty()); StackPane.setMargin(arrowButton, new Insets(0, 0, 0, isLeftArrow ? -4 : 4)); inner = new StackPane() { @Override protected double computePrefWidth(double height) { double preferWidth = 0.0d; double maxArrowWidth = !isControlButtonShown() ? 0 : snapSize(arrowRippler.prefWidth(getHeight())); preferWidth += isControlButtonShown() ? maxArrowWidth : 0; preferWidth += (preferWidth > 0) ? snappedLeftInset() + snappedRightInset() : 0; return preferWidth; } @Override protected double computePrefHeight(double width) { double prefHeight = 0.0d; prefHeight = isControlButtonShown() ? Math.max(prefHeight, snapSize(arrowRippler.prefHeight(width))) : 0; prefHeight += prefHeight > 0 ? snappedTopInset() + snappedBottomInset() : 0; return prefHeight; } @Override protected void layoutChildren() { if (isControlButtonShown()) { double x = 0; double y = snappedTopInset(); double width = snapSize(getWidth()) - x + snappedLeftInset(); double height = snapSize(getHeight()) - y + snappedBottomInset(); positionArrow(arrowRippler, x, y, width, height); } } private void positionArrow(JFXRippler rippler, double x, double y, double width, double height) { rippler.resize(width, height); positionInArea(rippler, x, y, width, height, 0, HPos.CENTER, VPos.CENTER); } }; arrowRippler.setPadding(new Insets(0, 5, 0, 5)); inner.getChildren().add(arrowRippler); StackPane.setMargin(arrowRippler, new Insets(0, 4, 0, 4)); getChildren().add(inner); showControlButtons = false; if (isControlButtonShown()) { showControlButtons = true; requestLayout(); } } private boolean showTabsHeaderControls = false; private void showTabsMenu(boolean value) { final boolean wasTabsMenuShowing = isControlButtonShown(); this.showTabsHeaderControls = value; if (showTabsHeaderControls && !wasTabsMenuShowing) { arrowButton.setVisible(true); showControlButtons = true; inner.requestLayout(); header.requestLayout(); } else if (!showTabsHeaderControls && wasTabsMenuShowing) { // hide control button if (isControlButtonShown()) { showControlButtons = true; } else { setVisible(false); } requestLayout(); } } private boolean isControlButtonShown() { return showTabsHeaderControls; } @Override protected double computePrefWidth(double height) { double prefWidth = snapSize(inner.prefWidth(height)); if (prefWidth > 0) { prefWidth += snappedLeftInset() + snappedRightInset(); } return prefWidth; } @Override protected double computePrefHeight(double width) { return Math.max(getSkinnable().getTabMinHeight(), snapSize(inner.prefHeight(width))) + snappedTopInset() + snappedBottomInset(); } @Override protected void layoutChildren() { double x = snappedLeftInset(); double y = snappedTopInset(); double width = snapSize(getWidth()) - x + snappedRightInset(); double height = snapSize(getHeight()) - y + snappedBottomInset(); if (showControlButtons) { setVisible(true); showControlButtons = false; } inner.resize(width, height); positionInArea(inner, x, y, width, height, 0, HPos.CENTER, VPos.BOTTOM); } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/skins/JFXToggleButtonSkin.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.skins; import com.jfoenix.controls.JFXRippler; import com.jfoenix.controls.JFXRippler.RipplerMask; import com.jfoenix.controls.JFXRippler.RipplerPos; import com.jfoenix.controls.JFXToggleButton; import com.jfoenix.effects.JFXDepthManager; import com.jfoenix.transitions.JFXAnimationTimer; import com.jfoenix.transitions.JFXKeyFrame; import com.jfoenix.transitions.JFXKeyValue; import javafx.animation.Interpolator; import javafx.geometry.Insets; import javafx.scene.Cursor; import javafx.scene.control.skin.ToggleButtonSkin; import javafx.scene.layout.StackPane; import javafx.scene.shape.Circle; import javafx.scene.shape.Line; import javafx.scene.shape.StrokeLineCap; import javafx.util.Duration; /** *

Material Design ToggleButton Skin

* * @author Shadi Shaheen * @version 1.0 * @since 2016-03-09 */ public class JFXToggleButtonSkin extends ToggleButtonSkin { private Runnable releaseManualRippler = null; private JFXAnimationTimer timer; private final Circle circle; private final Line line; public JFXToggleButtonSkin(JFXToggleButton toggleButton) { super(toggleButton); double circleRadius = toggleButton.getSize(); line = new Line(); line.setStroke(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor()); line.setStartX(0); line.setStartY(0); line.setEndX(circleRadius * 2 + 2); line.setEndY(0); line.setStrokeWidth(circleRadius * 1.5); line.setStrokeLineCap(StrokeLineCap.ROUND); line.setSmooth(true); circle = new Circle(); circle.setFill(getSkinnable().isSelected() ? toggleButton.getToggleColor() : toggleButton.getUnToggleColor()); circle.setCenterX(-circleRadius); circle.setCenterY(0); circle.setRadius(circleRadius); circle.setSmooth(true); JFXDepthManager.setDepth(circle, 1); StackPane circlePane = new StackPane(); circlePane.getChildren().add(circle); circlePane.setPadding(new Insets(circleRadius * 1.5)); JFXRippler rippler = new JFXRippler(circlePane, RipplerMask.CIRCLE, RipplerPos.BACK); rippler.setRipplerFill(getSkinnable().isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor()); rippler.setTranslateX(computeTranslation(circleRadius, line)); final StackPane main = new StackPane(); main.getChildren().setAll(line, rippler); main.setCursor(Cursor.HAND); // show focus traversal effect getSkinnable().armedProperty().addListener((o, oldVal, newVal) -> { if (newVal) { releaseManualRippler = rippler.createManualRipple(); } else if (releaseManualRippler != null) { releaseManualRippler.run(); } }); toggleButton.focusedProperty().addListener((o, oldVal, newVal) -> { if (!toggleButton.isDisableVisualFocus()) { if (newVal) { if (!getSkinnable().isPressed()) { rippler.setOverlayVisible(true); } } else { rippler.setOverlayVisible(false); } } }); toggleButton.pressedProperty().addListener(observable -> rippler.setOverlayVisible(false)); // add change listener to selected property getSkinnable().selectedProperty().addListener(observable -> { rippler.setRipplerFill(toggleButton.isSelected() ? toggleButton.getToggleLineColor() : toggleButton.getUnToggleLineColor()); if (!toggleButton.isDisableAnimation()) { timer.reverseAndContinue(); } else { rippler.setTranslateX(computeTranslation(circleRadius, line)); } }); getSkinnable().setGraphic(main); timer = new JFXAnimationTimer( new JFXKeyFrame(Duration.millis(100), JFXKeyValue.builder() .setTarget(rippler.translateXProperty()) .setEndValueSupplier(() -> computeTranslation(circleRadius, line)) .setInterpolator(Interpolator.EASE_BOTH) .setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation()) .build(), JFXKeyValue.builder() .setTarget(line.strokeProperty()) .setEndValueSupplier(() -> getSkinnable().isSelected() ? ((JFXToggleButton) getSkinnable()).getToggleLineColor() : ((JFXToggleButton) getSkinnable()).getUnToggleLineColor()) .setInterpolator(Interpolator.EASE_BOTH) .setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation()) .build(), JFXKeyValue.builder() .setTarget(circle.fillProperty()) .setEndValueSupplier(() -> getSkinnable().isSelected() ? ((JFXToggleButton) getSkinnable()).getToggleColor() : ((JFXToggleButton) getSkinnable()).getUnToggleColor()) .setInterpolator(Interpolator.EASE_BOTH) .setAnimateCondition(() -> !((JFXToggleButton) getSkinnable()).isDisableAnimation()) .build() ) ); timer.setCacheNodes(circle, line); registerChangeListener(toggleButton.toggleColorProperty(), observableValue -> { if (getSkinnable().isSelected()) { circle.setFill(((JFXToggleButton) getSkinnable()).getToggleColor()); } }); registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> { if (!getSkinnable().isSelected()) { circle.setFill(((JFXToggleButton) getSkinnable()).getUnToggleColor()); } }); registerChangeListener(toggleButton.toggleLineColorProperty(), observableValue -> { if (getSkinnable().isSelected()) { line.setStroke(((JFXToggleButton) getSkinnable()).getToggleLineColor()); } }); registerChangeListener(toggleButton.unToggleColorProperty(), observableValue -> { if (!getSkinnable().isSelected()) { line.setStroke(((JFXToggleButton) getSkinnable()).getUnToggleLineColor()); } }); } private double computeTranslation(double circleRadius, Line line) { return (getSkinnable().isSelected() ? 1 : -1) * ((line.getLayoutBounds().getWidth() / 2) - circleRadius + 2); } @Override public void dispose() { super.dispose(); timer.dispose(); timer = null; } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/transitions/CacheMemento.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.transitions; import javafx.scene.CacheHint; import javafx.scene.Node; import javafx.scene.layout.Region; import java.util.concurrent.atomic.AtomicBoolean; public final class CacheMemento { private boolean cache; private boolean cacheShape; private boolean snapToPixel; private CacheHint cacheHint = CacheHint.DEFAULT; private final Node node; private final AtomicBoolean isCached = new AtomicBoolean(false); public CacheMemento(Node node) { this.node = node; } /** * this method will cache the node only if it wasn't cached before */ public void cache() { if (!isCached.getAndSet(true)) { this.cache = node.isCache(); this.cacheHint = node.getCacheHint(); node.setCache(true); node.setCacheHint(CacheHint.SPEED); if (node instanceof Region region) { this.cacheShape = region.isCacheShape(); this.snapToPixel = region.isSnapToPixel(); region.setCacheShape(true); region.setSnapToPixel(true); } } } public void restore() { if (isCached.getAndSet(false)) { node.setCache(cache); node.setCacheHint(cacheHint); if (node instanceof Region region) { region.setCacheShape(cacheShape); region.setSnapToPixel(snapToPixel); } } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/transitions/JFXAnimationTimer.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.transitions; import javafx.animation.AnimationTimer; import javafx.beans.value.WritableValue; import javafx.scene.Node; import javafx.util.Duration; import java.util.*; import java.util.function.Supplier; /** * Custom AnimationTimer that can be created the same way as a timeline, * however it doesn't behave the same yet. it only animates in one direction, * it doesn't support animation 0 -> 1 -> 0.5 * * @author Shadi Shaheen * @version 1.0 * @since 2017-09-21 */ public class JFXAnimationTimer extends AnimationTimer { private Set animationHandlers = new HashSet<>(); private long startTime = -1; private boolean running = false; private List caches = new ArrayList<>(); private double totalElapsedMilliseconds; public JFXAnimationTimer(JFXKeyFrame... keyFrames) { for (JFXKeyFrame keyFrame : keyFrames) { Duration duration = keyFrame.getDuration(); final Set> keyValuesSet = keyFrame.getValues(); if (!keyValuesSet.isEmpty()) { animationHandlers.add(new AnimationHandler(duration, keyFrame.getAnimateCondition(), keyFrame.getValues())); } } } private final HashMap mutableFrames = new HashMap<>(); public void addKeyFrame(JFXKeyFrame keyFrame) throws Exception { if (isRunning()) { throw new Exception("Can't update animation timer while running"); } Duration duration = keyFrame.getDuration(); final Set> keyValuesSet = keyFrame.getValues(); if (!keyValuesSet.isEmpty()) { final AnimationHandler handler = new AnimationHandler(duration, keyFrame.getAnimateCondition(), keyFrame.getValues()); animationHandlers.add(handler); mutableFrames.put(keyFrame, handler); } } public void removeKeyFrame(JFXKeyFrame keyFrame) throws Exception { if (isRunning()) { throw new Exception("Can't update animation timer while running"); } AnimationHandler handler = mutableFrames.get(keyFrame); animationHandlers.remove(handler); } @Override public void start() { super.start(); running = true; startTime = -1; for (AnimationHandler animationHandler : animationHandlers) { animationHandler.init(); } for (CacheMemento cache : caches) { cache.cache(); } } @Override public void handle(long now) { startTime = startTime == -1 ? now : startTime; totalElapsedMilliseconds = (now - startTime) / 1000000.0; boolean stop = true; for (AnimationHandler handler : animationHandlers) { handler.animate(totalElapsedMilliseconds); if (!handler.finished) { stop = false; } } if (stop) { this.stop(); } } /** * this method will pause the timer and reverse the animation if the timer already * started otherwise it will start the animation. */ public void reverseAndContinue() { if (isRunning()) { super.stop(); for (AnimationHandler handler : animationHandlers) { handler.reverse(totalElapsedMilliseconds); } startTime = -1; super.start(); } else { start(); } } @Override public void stop() { super.stop(); running = false; for (AnimationHandler handler : animationHandlers) { handler.clear(); } for (CacheMemento cache : caches) { cache.restore(); } if (onFinished != null) { onFinished.run(); } } public void applyEndValues() { if (isRunning()) { super.stop(); } for (AnimationHandler handler : animationHandlers) { handler.applyEndValues(); } startTime = -1; } public boolean isRunning() { return running; } private Runnable onFinished = null; public void setOnFinished(Runnable onFinished) { this.onFinished = onFinished; } public void setCacheNodes(Node... nodesToCache) { caches.clear(); if (nodesToCache != null) { for (Node node : nodesToCache) { caches.add(new CacheMemento(node)); } } } public void dispose() { caches.clear(); for (AnimationHandler handler : animationHandlers) { handler.dispose(); } animationHandlers.clear(); } static class AnimationHandler { private final double duration; private double currentDuration; private final Set> keyValues; private Supplier animationCondition = null; private boolean finished = false; private final HashMap, Object> initialValuesMap = new HashMap<>(); private final HashMap, Object> endValuesMap = new HashMap<>(); AnimationHandler(Duration duration, Supplier animationCondition, Set> keyValues) { this.duration = duration.toMillis(); currentDuration = this.duration; this.keyValues = keyValues; this.animationCondition = animationCondition; } public void init() { finished = animationCondition != null && !animationCondition.get(); for (JFXKeyValue keyValue : keyValues) { if (keyValue.getTarget() != null) { // replaced putIfAbsent for mobile compatibility if (!initialValuesMap.containsKey(keyValue.getTarget())) { initialValuesMap.put(keyValue.getTarget(), keyValue.getTarget().getValue()); } if (!endValuesMap.containsKey(keyValue.getTarget())) { endValuesMap.put(keyValue.getTarget(), keyValue.getEndValue()); } } } } void reverse(double now) { finished = animationCondition != null && !animationCondition.get(); currentDuration = duration - (currentDuration - now); // update initial values for (JFXKeyValue keyValue : keyValues) { final WritableValue target = keyValue.getTarget(); if (target != null) { initialValuesMap.put(target, target.getValue()); endValuesMap.put(target, keyValue.getEndValue()); } } } // now in milliseconds @SuppressWarnings({"unchecked"}) public void animate(double now) { // if animate condition for the key frame is not met then do nothing if (finished) { return; } if (now <= currentDuration) { for (JFXKeyValue keyValue : keyValues) { if (keyValue.isValid()) { @SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget(); final Object endValue = endValuesMap.get(target); if (endValue != null && target != null && !target.getValue().equals(endValue)) { target.setValue(keyValue.getInterpolator().interpolate(initialValuesMap.get(target), endValue, now / currentDuration)); } } } } else { if (!finished) { finished = true; for (JFXKeyValue keyValue : keyValues) { if (keyValue.isValid()) { @SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget(); if (target != null) { // set updated end value instead of cached final Object endValue = keyValue.getEndValue(); if (endValue != null) { target.setValue(endValue); } } } } currentDuration = duration; } } } @SuppressWarnings("unchecked") public void applyEndValues() { for (JFXKeyValue keyValue : keyValues) { if (keyValue.isValid()) { @SuppressWarnings("rawtypes") final WritableValue target = keyValue.getTarget(); if (target != null) { final Object endValue = keyValue.getEndValue(); if (endValue != null && !target.getValue().equals(endValue)) { target.setValue(endValue); } } } } } public void clear() { initialValuesMap.clear(); endValuesMap.clear(); } void dispose() { clear(); keyValues.clear(); } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/transitions/JFXKeyFrame.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.transitions; import javafx.util.Duration; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import java.util.function.Supplier; /** * @author Shadi Shaheen * @version 1.0 * @since 2017-09-21 */ public class JFXKeyFrame { private Duration duration; private Set> keyValues = new CopyOnWriteArraySet<>(); private Supplier animateCondition = null; public JFXKeyFrame(Duration duration, JFXKeyValue... keyValues) { this.duration = duration; for (final JFXKeyValue keyValue : keyValues) { if (keyValue != null) { this.keyValues.add(keyValue); } } } private JFXKeyFrame() { } public final Duration getDuration() { return duration; } public final Set> getValues() { return keyValues; } public Supplier getAnimateCondition() { return animateCondition; } public static Builder builder() { return new Builder(); } public static final class Builder { private Duration duration; private final Set> keyValues = new CopyOnWriteArraySet<>(); private Supplier animateCondition = null; private Builder() { } public Builder setDuration(Duration duration) { this.duration = duration; return this; } public Builder setKeyValues(JFXKeyValue... keyValues) { for (final JFXKeyValue keyValue : keyValues) { if (keyValue != null) { this.keyValues.add(keyValue); } } return this; } public Builder setAnimateCondition(Supplier animateCondition) { this.animateCondition = animateCondition; return this; } public JFXKeyFrame build() { JFXKeyFrame jFXKeyFrame = new JFXKeyFrame(); jFXKeyFrame.duration = this.duration; jFXKeyFrame.keyValues = this.keyValues; jFXKeyFrame.animateCondition = this.animateCondition; return jFXKeyFrame; } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/transitions/JFXKeyValue.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.transitions; import javafx.animation.Interpolator; import javafx.beans.value.WritableValue; import java.util.function.Supplier; /** * @author Shadi Shaheen * @version 1.0 * @since 2017-09-21 */ public final class JFXKeyValue { private WritableValue target; private Supplier> targetSupplier; private Supplier endValueSupplier; private T endValue; private Supplier animateCondition = () -> true; private Interpolator interpolator; private JFXKeyValue() { } // this builder is created to ensure type inference from method arguments public static Builder builder() { return new Builder(); } public T getEndValue() { return endValue == null ? endValueSupplier.get() : endValue; } public WritableValue getTarget() { return target == null ? targetSupplier.get() : target; } public Interpolator getInterpolator() { return interpolator; } public boolean isValid() { return animateCondition == null || animateCondition.get(); } public static final class Builder { public JFXKeyValueBuilder setTarget(WritableValue target) { JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); builder.setTarget(target); return builder; } public JFXKeyValueBuilder setTargetSupplier(Supplier> targetSupplier) { JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); builder.setTargetSupplier(targetSupplier); return builder; } public JFXKeyValueBuilder setEndValueSupplier(Supplier endValueSupplier) { JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); builder.setEndValueSupplier(endValueSupplier); return builder; } public JFXKeyValueBuilder setEndValue(T endValue) { JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); builder.setEndValue(endValue); return builder; } public JFXKeyValueBuilder setAnimateCondition(Supplier animateCondition) { JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); builder.setAnimateCondition(animateCondition); return builder; } public JFXKeyValueBuilder setInterpolator(Interpolator interpolator) { JFXKeyValueBuilder builder = new JFXKeyValueBuilder<>(); builder.setInterpolator(interpolator); return builder; } } public static final class JFXKeyValueBuilder { private WritableValue target; private Supplier> targetSupplier; private Supplier endValueSupplier; private T endValue; private Supplier animateCondition = () -> true; private Interpolator interpolator = Interpolator.EASE_BOTH; private JFXKeyValueBuilder() { } public JFXKeyValueBuilder setTarget(WritableValue target) { this.target = target; return this; } public JFXKeyValueBuilder setTargetSupplier(Supplier> targetSupplier) { this.targetSupplier = targetSupplier; return this; } public JFXKeyValueBuilder setEndValueSupplier(Supplier endValueSupplier) { this.endValueSupplier = endValueSupplier; return this; } public JFXKeyValueBuilder setEndValue(T endValue) { this.endValue = endValue; return this; } public JFXKeyValueBuilder setAnimateCondition(Supplier animateCondition) { this.animateCondition = animateCondition; return this; } public JFXKeyValueBuilder setInterpolator(Interpolator interpolator) { this.interpolator = interpolator; return this; } public JFXKeyValue build() { JFXKeyValue jFXKeyValue = new JFXKeyValue<>(); jFXKeyValue.target = this.target; jFXKeyValue.interpolator = this.interpolator; jFXKeyValue.targetSupplier = this.targetSupplier; jFXKeyValue.endValue = this.endValue; jFXKeyValue.endValueSupplier = this.endValueSupplier; jFXKeyValue.animateCondition = this.animateCondition; return jFXKeyValue; } } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/utils/JFXNodeUtils.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ package com.jfoenix.utils; import javafx.beans.value.ObservableBooleanValue; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.Region; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.stage.Window; import org.jetbrains.annotations.NotNull; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.reflect.Method; import java.util.Locale; import java.util.function.Function; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// @author Shadi Shaheen /// @version 1.0 /// @since 2017-02-11 public final class JFXNodeUtils { public static void updateBackground(Background newBackground, Region nodeToUpdate) { updateBackground(newBackground, nodeToUpdate, Color.BLACK); } public static void updateBackground(Background newBackground, Region nodeToUpdate, Paint fill) { if (newBackground != null && !newBackground.getFills().isEmpty()) { final BackgroundFill[] fills = new BackgroundFill[newBackground.getFills().size()]; for (int i = 0; i < newBackground.getFills().size(); i++) { BackgroundFill bf = newBackground.getFills().get(i); fills[i] = new BackgroundFill(fill, bf.getRadii(), bf.getInsets()); } nodeToUpdate.setBackground(new Background(fills)); } } public static String colorToHex(Color c) { if (c != null) { return String.format((Locale) null, "#%02X%02X%02X", Math.round(c.getRed() * 255), Math.round(c.getGreen() * 255), Math.round(c.getBlue() * 255)); } else { return null; } } private static final @NotNull Function treeVisiblePropertyGetter = initTreeVisiblePropertyGetter(); private static @NotNull Function initTreeVisiblePropertyGetter() { MethodHandles.Lookup lookup; try { lookup = MethodHandles.privateLookupIn(Node.class, MethodHandles.lookup()); } catch (IllegalAccessException e) { LOG.warning("Failed to get private lookup for Node", e); return JFXNodeUtils::defaultTreeVisibleProperty; } try { Method treeVisiblePropertyMethod = Node.class.getDeclaredMethod("treeVisibleProperty"); if (!ObservableBooleanValue.class.isAssignableFrom(treeVisiblePropertyMethod.getReturnType())) { LOG.warning("Node.treeVisibleProperty() does not return ObservableBooleanValue: " + treeVisiblePropertyMethod.getReturnType()); return JFXNodeUtils::defaultTreeVisibleProperty; } MethodHandle handle = lookup.unreflect(treeVisiblePropertyMethod) .asType(MethodType.methodType(ObservableBooleanValue.class, Node.class)); return item -> { try { return (ObservableBooleanValue) handle.invokeExact((Node) item); } catch (RuntimeException | Error e) { throw e; } catch (Throwable e) { throw new AssertionError("Unreachable", e); } }; } catch (Exception e) { LOG.warning("Failed to get method handle for Node.treeVisibleProperty()", e); return JFXNodeUtils::defaultTreeVisibleProperty; } } /// If `Node.treeVisibleProperty()` does not exist, use `Node.visibleProperty()` as a fallback private static @NotNull ObservableBooleanValue defaultTreeVisibleProperty(Node item) { return item.visibleProperty(); } public static @NotNull ObservableBooleanValue treeVisibleProperty(Node item) { return treeVisiblePropertyGetter.apply(item); } public static boolean isTreeVisible(Node item) { return treeVisibleProperty(item).getValue(); } public static boolean isTreeShowing(Node node) { if (node == null) return false; Scene scene = node.getScene(); if (scene == null) return false; Window window = scene.getWindow(); if (window == null || !window.isShowing()) return false; return isTreeVisible(node); } private JFXNodeUtils() { } } ================================================ FILE: HMCL/src/main/java/com/jfoenix/utils/TreeShowingProperty.java ================================================ /* * Copyright (c) 2021, 2023, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it * under the terms of the GNU General Public License version 2 only, as * published by the Free Software Foundation. Oracle designates this * particular file as subject to the "Classpath" exception as provided * by Oracle in the LICENSE file that accompanied this code. * * This code 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 * version 2 for more details (a copy is included in the LICENSE file that * accompanied this code). * * You should have received a copy of the GNU General Public License version * 2 along with this work; if not, write to the Free Software Foundation, * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. * * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA * or visit www.oracle.com if you need additional information or have any * questions. */ package com.jfoenix.utils; import javafx.beans.property.ReadOnlyBooleanPropertyBase; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableBooleanValue; import javafx.scene.Node; import javafx.scene.Scene; import javafx.stage.Window; /** * Used to observe changes in tree showing status for a {@link Node}. For a Node's tree to be showing * it must be visible, its ancestors must be visible, the node must be part of a {@link Scene} and * the scene must have a {@link Window} which is currently showing.

* * This class provides the exact same functionality as {@link JFXNodeUtils#isTreeShowing(Node)} in * an observable form. */ public class TreeShowingProperty extends ReadOnlyBooleanPropertyBase { private final ChangeListener windowShowingChangedListener = (obs, old, current) -> updateTreeShowing(); private final ChangeListener sceneWindowChangedListener = (obs, old, current) -> windowChanged(old, current); private final ChangeListener nodeSceneChangedListener = (obs, old, current) -> sceneChanged(old, current); private final Node node; private final ObservableBooleanValue treeVisibleProperty; private boolean valid; private boolean treeShowing; /** * Constructs a new instance. * * @param node a {@link Node} for which the tree showing status should be observed, cannot be null */ public TreeShowingProperty(Node node) { this.node = node; this.treeVisibleProperty = JFXNodeUtils.treeVisibleProperty(node); this.node.sceneProperty().addListener(nodeSceneChangedListener); this.treeVisibleProperty.addListener(windowShowingChangedListener); sceneChanged(null, node.getScene()); } @Override public Object getBean() { return node; } @Override public String getName() { return "treeShowing"; } /** * Cleans up any listeners that this class may have registered on the {@link Node} * that was supplied at construction. */ public void dispose() { node.sceneProperty().removeListener(nodeSceneChangedListener); if (treeVisibleProperty != null) treeVisibleProperty.removeListener(windowShowingChangedListener); valid = false; // prevents unregistration from triggering an invalidation notification sceneChanged(node.getScene(), null); } protected void invalidate() { if (valid) { valid = false; fireValueChangedEvent(); } } @Override public boolean get() { if (!valid) { updateTreeShowing(); valid = true; } return treeShowing; } private void sceneChanged(Scene oldScene, Scene newScene) { if (oldScene != null) { oldScene.windowProperty().removeListener(sceneWindowChangedListener); } if (newScene != null) { newScene.windowProperty().addListener(sceneWindowChangedListener); } windowChanged( oldScene == null ? null : oldScene.getWindow(), newScene == null ? null : newScene.getWindow() ); } private void windowChanged(Window oldWindow, Window newWindow) { if (oldWindow != null) { oldWindow.showingProperty().removeListener(windowShowingChangedListener); } if (newWindow != null) { newWindow.showingProperty().addListener(windowShowingChangedListener); } updateTreeShowing(); } private void updateTreeShowing() { boolean newValue = JFXNodeUtils.isTreeShowing(node); if (newValue != treeShowing) { treeShowing = newValue; invalidate(); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/EntryPoint.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.SelfDependencyPatcher; import org.jackhuang.hmcl.util.SwingUtils; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.IOException; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.nio.file.Files; import java.nio.file.Path; import java.util.concurrent.CancellationException; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class EntryPoint { private EntryPoint() { } public static void main(String[] args) { System.getProperties().putIfAbsent("java.net.useSystemProxies", "true"); System.getProperties().putIfAbsent("javafx.autoproxy.disable", "true"); System.getProperties().putIfAbsent("http.agent", "HMCL/" + Metadata.VERSION); createHMCLDirectories(); LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs")); setupJavaFXVMOptions(); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { System.getProperties().putIfAbsent("apple.awt.application.appearance", "system"); if (!isInsideMacAppBundle()) initIcon(); } checkJavaFX(); verifyJavaFX(); addEnableNativeAccess(); enableUnsafeMemoryAccess(); Launcher.main(args); } public static void exit(int exitCode) { FileSaver.shutdown(); LOG.shutdown(); System.exit(exitCode); } private static void setupJavaFXVMOptions() { if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) { LOG.info("HMCL_FORCE_GPU: true"); System.getProperties().putIfAbsent("prism.forceGPU", "true"); } String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE"); if (animationFrameRate != null) { LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate); try { if (Integer.parseInt(animationFrameRate) <= 0) throw new NumberFormatException(animationFrameRate); System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate); } catch (NumberFormatException e) { LOG.warning("Invalid animation frame rate: " + animationFrameRate); } } String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE")); if (uiScale != null) { uiScale = uiScale.trim(); LOG.info("HMCL_UI_SCALE: " + uiScale); try { float scaleValue; if (uiScale.endsWith("%")) { scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f; } else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) { scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f; } else { scaleValue = Float.parseFloat(uiScale); } float lowerBound; float upperBound; if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { // JavaFX behavior may be abnormal when the DPI scaling factor is too high lowerBound = 0.25f; upperBound = 4f; } else { lowerBound = 0.01f; upperBound = 10f; } if (scaleValue >= lowerBound && scaleValue <= upperBound) { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { System.getProperties().putIfAbsent("glass.win.uiScale", uiScale); } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { LOG.warning("macOS does not support setting UI scale, so it will be ignored"); } else { System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale); } } else { LOG.warning("UI scale out of range: " + uiScale); } } catch (Throwable e) { LOG.warning("Invalid UI scale: " + uiScale); } } } private static void createHMCLDirectories() { if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) { try { Files.createDirectories(Metadata.HMCL_CURRENT_DIRECTORY); if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { try { Files.setAttribute(Metadata.HMCL_CURRENT_DIRECTORY, "dos:hidden", true); } catch (IOException e) { LOG.warning("Failed to set hidden attribute of " + Metadata.HMCL_CURRENT_DIRECTORY, e); } } } catch (IOException e) { // Logger has not been started yet, so print directly to System.err System.err.println("Failed to create HMCL directory: " + Metadata.HMCL_CURRENT_DIRECTORY); e.printStackTrace(System.err); showErrorAndExit(i18n("fatal.create_hmcl_current_directory_failure", Metadata.HMCL_CURRENT_DIRECTORY)); } } if (!Files.isDirectory(Metadata.HMCL_GLOBAL_DIRECTORY)) { try { Files.createDirectories(Metadata.HMCL_GLOBAL_DIRECTORY); } catch (IOException e) { LOG.warning("Failed to create HMCL global directory " + Metadata.HMCL_GLOBAL_DIRECTORY, e); } } } private static boolean isInsideMacAppBundle() { Path thisJar = JarUtils.thisJarPath(); if (thisJar == null) return false; for (Path current = thisJar.getParent(); current != null && current.getParent() != null; current = current.getParent() ) { if ("Contents".equals(FileUtils.getName(current)) && FileUtils.getName(current.getParent()).endsWith(".app") && Files.exists(current.resolve("Info.plist")) ) { return true; } } return false; } private static void initIcon() { try { if (java.awt.Taskbar.isTaskbarSupported()) { var image = java.awt.Toolkit.getDefaultToolkit().getImage(EntryPoint.class.getResource("/assets/img/icon-mac.png")); java.awt.Taskbar.getTaskbar().setIconImage(image); } } catch (Throwable e) { LOG.warning("Failed to set application icon", e); } } private static void checkJavaFX() { try { SelfDependencyPatcher.patch(); } catch (SelfDependencyPatcher.PatchException e) { LOG.error("Unable to patch JVM", e); showErrorAndExit(i18n("fatal.javafx.missing")); } catch (CancellationException e) { LOG.error("User cancels downloading JavaFX", e); exit(0); } } /** * Check if JavaFX exists but is incomplete */ private static void verifyJavaFX() { try { Class.forName("javafx.beans.binding.Binding"); // javafx.base Class.forName("javafx.stage.Stage"); // javafx.graphics Class.forName("javafx.scene.control.Skin"); // javafx.controls } catch (Exception e) { LOG.warning("JavaFX is incomplete or not found", e); showErrorAndExit(i18n("fatal.javafx.incomplete")); } } private static void addEnableNativeAccess() { if (JavaRuntime.CURRENT_VERSION > 21) { try { // javafx.graphics Module module = Class.forName("javafx.stage.Stage").getModule(); if (module.isNamed()) { try { MethodHandles.Lookup lookup = MethodHandles.privateLookupIn(Module.class, MethodHandles.lookup()); MethodHandle implAddEnableNativeAccess = lookup.findVirtual(Module.class, "implAddEnableNativeAccess", MethodType.methodType(Module.class)); Module ignored = (Module) implAddEnableNativeAccess.invokeExact(module); } catch (Throwable e) { e.printStackTrace(System.err); } } } catch (ClassNotFoundException e) { LOG.error("Failed to add enable native access for JavaFX", e); showErrorAndExit(i18n("fatal.javafx.incomplete")); } } } private static void enableUnsafeMemoryAccess() { // https://openjdk.org/jeps/498 if (JavaRuntime.CURRENT_VERSION == 24 || JavaRuntime.CURRENT_VERSION == 25) { try { Class clazz = Class.forName("sun.misc.Unsafe"); boolean ignored = (boolean) MethodHandles.privateLookupIn(clazz, MethodHandles.lookup()) .findStatic(clazz, "trySetMemoryAccessWarned", MethodType.methodType(boolean.class)) .invokeExact(); } catch (Throwable e) { LOG.warning("Failed to enable unsafe memory access", e); } } } /** * Indicates that a fatal error has occurred, and that the application cannot start. */ private static void showErrorAndExit(String message) { SwingUtils.showErrorDialog(message); exit(1); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl; import javafx.application.Application; import javafx.application.Platform; import javafx.beans.value.ObservableBooleanValue; import javafx.geometry.Rectangle2D; import javafx.scene.control.Alert; import javafx.scene.control.Alert.AlertType; import javafx.scene.control.ButtonType; import javafx.scene.input.Clipboard; import javafx.scene.input.DataFormat; import javafx.stage.Screen; import javafx.stage.Stage; import org.jackhuang.hmcl.setting.ConfigHolder; import org.jackhuang.hmcl.setting.SambaException; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.task.AsyncTaskExecutor; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.upgrade.UpdateHandler; import org.jackhuang.hmcl.util.CrashReporter; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.CommandBuilder; import org.jackhuang.hmcl.util.platform.NativeUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.SystemInfo; import java.io.File; import java.io.IOException; import java.lang.management.ManagementFactory; import java.lang.management.MemoryPoolMXBean; import java.net.CookieHandler; import java.net.CookieManager; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class Launcher extends Application { public static final CookieManager COOKIE_MANAGER = new CookieManager(); @Override public void start(Stage primaryStage) { Thread.currentThread().setUncaughtExceptionHandler(CRASH_REPORTER); CookieHandler.setDefault(COOKIE_MANAGER); LOG.info("JavaFX Version: " + System.getProperty("javafx.runtime.version")); LOG.info("Prism Pipeline: " + FXUtils.GRAPHICS_PIPELINE); LOG.info("Dark Mode: " + Optional.ofNullable(FXUtils.DARK_MODE).map(ObservableBooleanValue::get).orElse(false)); LOG.info("Reduced Motion: " + Objects.requireNonNullElse(FXUtils.REDUCED_MOTION, false)); if (Screen.getScreens().isEmpty()) { LOG.info("No screen"); } else { StringBuilder builder = new StringBuilder("Screens:"); int count = 0; for (Screen screen : Screen.getScreens()) { builder.append("\n - Screen ").append(++count).append(": "); appendScreen(builder, screen); } LOG.info(builder.toString()); } try { try { ConfigHolder.init(); } catch (SambaException e) { showAlert(AlertType.WARNING, i18n("fatal.samba")); } catch (IOException e) { LOG.error("Failed to load config", e); checkConfigInTempDir(); checkConfigOwner(); showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", ConfigHolder.configLocation().getParent())); EntryPoint.exit(1); } // https://lapcatsoftware.com/articles/app-translocation.html if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && ConfigHolder.isNewlyCreated() && System.getProperty("user.dir").startsWith("/private/var/folders/")) { if (showAlert(AlertType.WARNING, i18n("fatal.mac_app_translocation"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) return; } else { checkConfigInTempDir(); } if (ConfigHolder.isOwnerChanged()) { if (showAlert(AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) return; } if (ConfigHolder.isUnsupportedVersion()) { showAlert(AlertType.WARNING, i18n("fatal.config_unsupported_version")); } if (Metadata.HMCL_CURRENT_DIRECTORY.toString().indexOf('=') >= 0) { showAlert(AlertType.WARNING, i18n("fatal.illegal_char")); } // runLater to ensure ConfigHolder.init() finished initialization Platform.runLater(() -> { // When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false, // Stage.show() cannot work again because JavaFX Toolkit have already shut down. Platform.setImplicitExit(false); Controllers.initialize(primaryStage); UpdateChecker.init(); primaryStage.show(); }); } catch (Throwable e) { CRASH_REPORTER.uncaughtException(Thread.currentThread(), e); } } private static void appendScreen(StringBuilder builder, Screen screen) { Rectangle2D bounds = screen.getBounds(); double scale = screen.getOutputScaleX(); builder.append(Math.round(bounds.getWidth() * scale)); builder.append('x'); builder.append(Math.round(bounds.getHeight() * scale)); DecimalFormat decimalFormat = new DecimalFormat("#.##"); if (scale != 1.0) { builder.append(" @ "); builder.append(decimalFormat.format(scale)); builder.append('x'); } double dpi = screen.getDpi(); builder.append(' '); builder.append(decimalFormat.format(dpi)); builder.append("dpi"); builder.append(" in ") .append(Math.round(Math.sqrt(bounds.getWidth() * bounds.getWidth() + bounds.getHeight() * bounds.getHeight()) / dpi)) .append('"'); builder.append(" (").append(decimalFormat.format(bounds.getMinX())) .append(", ").append(decimalFormat.format(bounds.getMinY())) .append(", ").append(decimalFormat.format(bounds.getMaxX())) .append(", ").append(decimalFormat.format(bounds.getMaxY())) .append(")"); } private static ButtonType showAlert(AlertType alertType, String contentText, ButtonType... buttons) { return new Alert(alertType, contentText, buttons).showAndWait().orElse(null); } private static boolean isConfigInTempDir() { String configPath = ConfigHolder.configLocation().toString(); String tmpdir = System.getProperty("java.io.tmpdir"); if (StringUtils.isNotBlank(tmpdir) && configPath.startsWith(tmpdir)) return true; String[] tempFolderNames = {"Temp", "Cache", "Caches"}; for (String name : tempFolderNames) { if (configPath.contains(File.separator + name + File.separator)) return true; } if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { return configPath.contains("\\Temporary Internet Files\\") || configPath.contains("\\INetCache\\") || configPath.contains("\\$Recycle.Bin\\") || configPath.contains("\\recycler\\"); } else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { return configPath.startsWith("/tmp/") || configPath.startsWith("/var/tmp/") || configPath.startsWith("/var/cache/") || configPath.startsWith("/dev/shm/") || configPath.contains("/Trash/"); } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { return configPath.startsWith("/var/folders/") || configPath.startsWith("/private/var/folders/") || configPath.startsWith("/tmp/") || configPath.startsWith("/private/tmp/") || configPath.startsWith("/var/tmp/") || configPath.startsWith("/private/var/tmp/") || configPath.contains("/.Trash/"); } else { return false; } } private static void checkConfigInTempDir() { if (ConfigHolder.isNewlyCreated() && isConfigInTempDir() && showAlert(AlertType.WARNING, i18n("fatal.config_in_temp_dir"), ButtonType.YES, ButtonType.NO) == ButtonType.NO) { EntryPoint.exit(0); } } private static void checkConfigOwner() { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) return; String userName = System.getProperty("user.name"); String owner; try { owner = Files.getOwner(ConfigHolder.configLocation()).getName(); } catch (IOException ioe) { LOG.warning("Failed to get file owner", ioe); return; } if (Files.isWritable(ConfigHolder.configLocation()) || userName.equals("root") || userName.equals(owner)) return; ArrayList files = new ArrayList<>(); files.add(ConfigHolder.configLocation().toString()); if (Files.exists(Metadata.HMCL_GLOBAL_DIRECTORY)) files.add(Metadata.HMCL_GLOBAL_DIRECTORY.toString()); if (Files.exists(Metadata.HMCL_CURRENT_DIRECTORY)) files.add(Metadata.HMCL_CURRENT_DIRECTORY.toString()); Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize(); if (Files.exists(mcDir)) files.add(mcDir.toString()); String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString(); ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit")); if (showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure.unix", owner, command), copyAndExit, ButtonType.CLOSE) == copyAndExit) { Clipboard.getSystemClipboard() .setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command)); } EntryPoint.exit(1); } @Override public void stop() throws Exception { Controllers.onApplicationStop(); FileSaver.shutdown(); LOG.shutdown(); } public static void main(String[] args) { if (UpdateHandler.processArguments(args)) { LOG.shutdown(); return; } Thread.setDefaultUncaughtExceptionHandler(CRASH_REPORTER); AsyncTaskExecutor.setUncaughtExceptionHandler(new CrashReporter(false)); try { LOG.info("*** " + Metadata.TITLE + " ***"); LOG.info("Operating System: " + (OperatingSystem.OS_RELEASE_PRETTY_NAME == null ? OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() : OperatingSystem.OS_RELEASE_PRETTY_NAME + " (" + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + ')')); if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { LOG.info("Processor Identifier: " + System.getenv("PROCESSOR_IDENTIFIER")); } LOG.info("System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName()); LOG.info("Native Encoding: " + OperatingSystem.NATIVE_CHARSET); LOG.info("JNU Encoding: " + System.getProperty("sun.jnu.encoding")); if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { LOG.info("Code Page: " + OperatingSystem.CODE_PAGE); } LOG.info("Java Architecture: " + Architecture.CURRENT_ARCH.getDisplayName()); LOG.info("Java Version: " + System.getProperty("java.version") + ", " + System.getProperty("java.vendor")); LOG.info("Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor")); LOG.info("Java Home: " + System.getProperty("java.home")); LOG.info("Current Directory: " + Metadata.CURRENT_DIRECTORY); LOG.info("HMCL Global Directory: " + Metadata.HMCL_GLOBAL_DIRECTORY); LOG.info("HMCL Current Directory: " + Metadata.HMCL_CURRENT_DIRECTORY); LOG.info("HMCL Jar Path: " + Lang.requireNonNullElse(JarUtils.thisJarPath(), "Not Found")); LOG.info("HMCL Log File: " + Lang.requireNonNullElse(LOG.getLogFile(), "In Memory")); LOG.info("JVM Max Memory: " + MEGABYTES.formatBytes(Runtime.getRuntime().maxMemory())); try { for (MemoryPoolMXBean bean : ManagementFactory.getMemoryPoolMXBeans()) { if ("Metaspace".equals(bean.getName())) { long bytes = bean.getUsage().getUsed(); LOG.info("Metaspace: " + MEGABYTES.formatBytes(bytes)); break; } } } catch (NoClassDefFoundError ignored) { } LOG.info("Native Backend: " + (NativeUtils.USE_JNA ? "JNA" : "None")); if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { LOG.info("XDG Session Type: " + System.getenv("XDG_SESSION_TYPE")); LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP")); } Lang.thread(SystemInfo::initialize, "Detection System Information", true); launch(Launcher.class, args); } catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter. CRASH_REPORTER.uncaughtException(Thread.currentThread(), e); } } public static void stopApplication() { LOG.info("Stopping application.\n" + StringUtils.getStackTrace(Thread.currentThread().getStackTrace())); runInFX(() -> { if (Controllers.getStage() == null) return; Controllers.getStage().close(); Schedulers.shutdown(); Controllers.shutdown(); Platform.exit(); }); } public static void stopWithoutPlatform() { LOG.info("Stopping application without JavaFX Toolkit.\n" + StringUtils.getStackTrace(Thread.currentThread().getStackTrace())); runInFX(() -> { if (Controllers.getStage() == null) return; Controllers.getStage().close(); Schedulers.shutdown(); Controllers.shutdown(); Lang.executeDelayed(System::gc, TimeUnit.SECONDS, 5, true); }); } public static final CrashReporter CRASH_REPORTER = new CrashReporter(true); } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jetbrains.annotations.Nullable; import java.nio.file.Path; import java.nio.file.Paths; import java.util.EnumSet; /** * Stores metadata about this application. */ public final class Metadata { private Metadata() { } public static final String NAME = "HMCL"; public static final String FULL_NAME = "Hello Minecraft! Launcher"; public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getAttribute("hmcl.version", "@develop@")); public static final String TITLE = NAME + " " + VERSION; public static final String FULL_TITLE = FULL_NAME + " v" + VERSION; public static final int MINIMUM_REQUIRED_JAVA_VERSION = 17; public static final int MINIMUM_SUPPORTED_JAVA_VERSION = 17; public static final int RECOMMENDED_JAVA_VERSION = 21; public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net"; public static final String ABOUT_URL = PUBLISH_URL + "/about"; public static final String DOWNLOAD_URL = PUBLISH_URL + "/download"; public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link"); public static final String DOCS_URL = "https://docs.hmcl.net"; public static final String CONTACT_URL = DOCS_URL + "/help.html"; public static final String CHANGELOG_URL = DOCS_URL + "/changelog/"; public static final String EULA_URL = DOCS_URL + "/eula/hmcl.html"; public static final String GROUPS_URL = "https://www.bilibili.com/opus/905435541874409529"; public static final String BUILD_CHANNEL = JarUtils.getAttribute("hmcl.version.type", "nightly"); public static final String GITHUB_SHA = JarUtils.getAttribute("hmcl.version.hash", null); public static final Path CURRENT_DIRECTORY = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize(); public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft"); public static final Path HMCL_GLOBAL_DIRECTORY; public static final Path HMCL_CURRENT_DIRECTORY; public static final Path DEPENDENCIES_DIRECTORY; static { String hmclHome = System.getProperty("hmcl.home"); if (hmclHome == null) { if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) { String xdgData = System.getenv("XDG_DATA_HOME"); if (StringUtils.isNotBlank(xdgData)) { HMCL_GLOBAL_DIRECTORY = Paths.get(xdgData, "hmcl").toAbsolutePath().normalize(); } else { HMCL_GLOBAL_DIRECTORY = Paths.get(System.getProperty("user.home"), ".local", "share", "hmcl").toAbsolutePath().normalize(); } } else { HMCL_GLOBAL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl"); } } else { HMCL_GLOBAL_DIRECTORY = Paths.get(hmclHome).toAbsolutePath().normalize(); } String hmclCurrentDir = System.getProperty("hmcl.dir"); HMCL_CURRENT_DIRECTORY = hmclCurrentDir != null ? Paths.get(hmclCurrentDir).toAbsolutePath().normalize() : CURRENT_DIRECTORY.resolve(".hmcl"); DEPENDENCIES_DIRECTORY = HMCL_CURRENT_DIRECTORY.resolve("dependencies"); } public static boolean isStable() { return "stable".equals(BUILD_CHANNEL); } public static boolean isDev() { return "dev".equals(BUILD_CHANNEL); } public static boolean isNightly() { return !isStable() && !isDev(); } public static @Nullable String getSuggestedJavaDownloadLink() { if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW) return "https://www.loongnix.cn/zh/api/java/downloads-jdk21/index.html"; else { EnumSet supportedArchitectures; if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.X86, Architecture.ARM64); else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) supportedArchitectures = EnumSet.of( Architecture.X86_64, Architecture.X86, Architecture.ARM64, Architecture.ARM32, Architecture.RISCV64, Architecture.LOONGARCH64 ); else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) supportedArchitectures = EnumSet.of(Architecture.X86_64, Architecture.ARM64); else supportedArchitectures = EnumSet.noneOf(Architecture.class); if (supportedArchitectures.contains(Architecture.SYSTEM_ARCH)) return String.format("https://docs.hmcl.net/downloads/%s/%s.html", OperatingSystem.CURRENT_OS.getCheckedName(), Architecture.SYSTEM_ARCH.getCheckedName() ); else return null; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/countly/CrashReport.java ================================================ package org.jackhuang.hmcl.countly; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; public class CrashReport { private final Thread thread; private final Throwable throwable; private final String stackTrace; public CrashReport(Thread thread, Throwable throwable) { this.thread = thread; this.throwable = throwable; stackTrace = StringUtils.getStackTrace(throwable); } public Throwable getThrowable() { return this.throwable; } public boolean shouldBeReport() { if (!stackTrace.contains("org.jackhuang")) return false; if (throwable instanceof VirtualMachineError) return false; return true; } public String getDisplayText() { return "---- Hello Minecraft! Crash Report ----\n" + " Version: " + Metadata.VERSION + "\n" + " Time: " + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format(LocalDateTime.now()) + "\n" + " Thread: " + thread + "\n" + "\n Content: \n " + stackTrace + "\n\n" + "-- System Details --\n" + " Operating System: " + OperatingSystem.SYSTEM_NAME + ' ' + OperatingSystem.SYSTEM_VERSION.getVersion() + "\n" + " System Architecture: " + Architecture.SYSTEM_ARCH.getDisplayName() + "\n" + " Java Architecture: " + Architecture.CURRENT_ARCH.getDisplayName() + "\n" + " Java Version: " + System.getProperty("java.version") + ", " + System.getProperty("java.vendor") + "\n" + " Java VM Version: " + System.getProperty("java.vm.name") + " (" + System.getProperty("java.vm.info") + "), " + System.getProperty("java.vm.vendor") + "\n" + " JVM Max Memory: " + Runtime.getRuntime().maxMemory() + "\n" + " JVM Total Memory: " + Runtime.getRuntime().totalMemory() + "\n" + " JVM Free Memory: " + Runtime.getRuntime().freeMemory() + "\n"; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLCacheRepository.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import org.jackhuang.hmcl.download.DefaultCacheRepository; import java.nio.file.Paths; public class HMCLCacheRepository extends DefaultCacheRepository { private final StringProperty directory = new SimpleStringProperty(); public HMCLCacheRepository() { directory.addListener((a, b, t) -> changeDirectory(Paths.get(t))); } public String getDirectory() { return directory.get(); } public StringProperty directoryProperty() { return directory; } public void setDirectory(String directory) { this.directory.set(directory); } public static final HMCLCacheRepository REPOSITORY = new HMCLCacheRepository(); } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.launch.DefaultLauncher; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.io.IOException; import java.nio.file.*; import java.util.*; import java.util.stream.Stream; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author huangyuhui */ public final class HMCLGameLauncher extends DefaultLauncher { public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options) { this(repository, version, authInfo, options, null); } public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { this(repository, version, authInfo, options, listener, true); } public HMCLGameLauncher(GameRepository repository, Version version, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { super(repository, version, authInfo, options, listener, daemon); } @Override protected Map getConfigurations() { Map res = super.getConfigurations(); res.put("${launcher_name}", Metadata.NAME); res.put("${launcher_version}", Metadata.VERSION); return res; } private void generateOptionsTxt() { if (config().isDisableAutoGameOptions()) return; Path runDir = repository.getRunDirectory(version.getId()); Path optionsFile = runDir.resolve("options.txt"); Path configFolder = runDir.resolve("config"); if (Files.exists(optionsFile)) return; if (Files.isDirectory(configFolder)) { try (Stream stream = Files.walk(configFolder, 2, FileVisitOption.FOLLOW_LINKS)) { if (stream.anyMatch(file -> "options.txt".equals(FileUtils.getName(file)))) return; } catch (IOException e) { LOG.warning("Failed to visit config folder", e); } } Locale locale = Locale.getDefault(); /* * 1.0 : No language option, do not set for these versions * 1.1 ~ 1.5 : zh_CN works fine, zh_cn will crash (the last two letters must be uppercase, otherwise it will cause an NPE crash) * 1.6 ~ 1.10 : zh_CN works fine, zh_cn will automatically switch to English * 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected * 1.13+ : zh_cn works fine, zh_CN will automatically switch to English */ GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(version)); if (gameVersion.compareTo("1.1") < 0) return; String lang = normalizedLanguageTag(locale, gameVersion); if (lang.isEmpty()) return; if (gameVersion.compareTo("1.11") >= 0) lang = lang.toLowerCase(Locale.ROOT); try { Files.createDirectories(optionsFile.getParent()); Files.writeString(optionsFile, String.format("lang:%s\n", lang)); } catch (IOException e) { LOG.warning("Unable to generate options.txt", e); } } private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) { String region = locale.getCountry(); return switch (LocaleUtils.getRootLanguage(locale)) { case "ar" -> "ar_SA"; case "es" -> "es_ES"; case "ja" -> "ja_JP"; case "ru" -> "ru_RU"; case "uk" -> "uk_UA"; case "zh" -> { if ("lzh".equals(locale.getLanguage()) && gameVersion.compareTo("1.16") >= 0) yield "lzh"; String script = LocaleUtils.getScript(locale); if ("Hant".equals(script)) { if ((region.equals("HK") || region.equals("MO") && gameVersion.compareTo("1.16") >= 0)) yield "zh_HK"; yield "zh_TW"; } yield "zh_CN"; } case "en" -> { if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) { yield "en_UD"; } yield ""; } default -> ""; }; } @Override public ManagedProcess launch() throws IOException, InterruptedException { generateOptionsTxt(); return super.launch(); } @Override public void makeLaunchScript(Path scriptFile) throws IOException { generateOptionsTxt(); super.makeLaunchScript(scriptFile); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonParseException; import javafx.scene.image.Image; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.mod.ModAdviser; import org.jackhuang.hmcl.mod.Modpack; import org.jackhuang.hmcl.mod.ModpackConfiguration; import org.jackhuang.hmcl.mod.ModpackProvider; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.VersionIconType; import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.SystemInfo; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.net.Proxy; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.time.Instant; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class HMCLGameRepository extends DefaultGameRepository { private final Profile profile; // local version settings private final Map localVersionSettings = new HashMap<>(); private final Set beingModpackVersions = new HashSet<>(); public final EventManager onVersionIconChanged = new EventManager<>(); public HMCLGameRepository(Profile profile, Path baseDirectory) { super(baseDirectory); this.profile = profile; } public Profile getProfile() { return profile; } @Override public GameDirectoryType getGameDirectoryType(String id) { if (beingModpackVersions.contains(id) || isModpack(id)) { return GameDirectoryType.VERSION_FOLDER; } else { return getVersionSetting(id).getGameDirType(); } } @Override public Path getRunDirectory(String id) { switch (getGameDirectoryType(id)) { case VERSION_FOLDER: return getVersionRoot(id); case ROOT_FOLDER: return super.getRunDirectory(id); case CUSTOM: try { return Path.of(getVersionSetting(id).getGameDir()); } catch (InvalidPathException ignored) { return getVersionRoot(id); } default: throw new AssertionError("Unreachable"); } } public Stream getDisplayVersions() { return getVersions().stream() .filter(v -> !v.isHidden()) .sorted(Comparator.comparing((Version v) -> Lang.requireNonNullElse(v.getReleaseTime(), Instant.EPOCH)) .thenComparing(v -> VersionNumber.asVersion(v.getId()))); } @Override protected void refreshVersionsImpl() { localVersionSettings.clear(); super.refreshVersionsImpl(); versions.keySet().forEach(this::loadLocalVersionSetting); versions.keySet().forEach(version -> { if (isModpack(version)) { specializeVersionSetting(version); } }); try { Path file = getBaseDirectory().resolve("launcher_profiles.json"); if (!Files.exists(file) && !versions.isEmpty()) { Files.createDirectories(file.getParent()); Files.writeString(file, PROFILE); } } catch (IOException ex) { LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex); } } public void changeDirectory(Path newDirectory) { setBaseDirectory(newDirectory); refreshVersionsAsync().start(); } private void clean(Path directory) throws IOException { FileUtils.deleteDirectory(directory.resolve("crash-reports")); FileUtils.deleteDirectory(directory.resolve("logs")); } public void clean(String id) throws IOException { clean(getBaseDirectory()); clean(getRunDirectory(id)); } public void duplicateVersion(String srcId, String dstId, boolean copySaves) throws IOException { Path srcDir = getVersionRoot(srcId); Path dstDir = getVersionRoot(dstId); Version fromVersion = getVersion(srcId); List blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(srcId + ".jar"); blackList.add(srcId + ".json"); if (!copySaves) blackList.add("saves"); if (Files.exists(dstDir)) throw new IOException("Version exists"); Files.createDirectories(dstDir); FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null)); Path fromJson = srcDir.resolve(srcId + ".json"); Path fromJar = srcDir.resolve(srcId + ".jar"); Path toJson = dstDir.resolve(dstId + ".json"); Path toJar = dstDir.resolve(dstId + ".jar"); if (Files.exists(fromJar)) { Files.copy(fromJar, toJar); } Files.copy(fromJson, toJson); JsonUtils.writeToJsonFile(toJson, fromVersion.setId(dstId).setJar(dstId)); VersionSetting oldVersionSetting = getVersionSetting(srcId).clone(); GameDirectoryType originalGameDirType = oldVersionSetting.getGameDirType(); oldVersionSetting.setUsesGlobal(false); oldVersionSetting.setGameDirType(GameDirectoryType.VERSION_FOLDER); VersionSetting newVersionSetting = initLocalVersionSetting(dstId, oldVersionSetting); saveVersionSetting(dstId); Path srcGameDir = getRunDirectory(srcId); Path dstGameDir = getRunDirectory(dstId); if (originalGameDirType != GameDirectoryType.VERSION_FOLDER) FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); } private Path getLocalVersionSettingFile(String id) { return getVersionRoot(id).resolve("hmclversion.cfg"); } private void loadLocalVersionSetting(String id) { Path file = getLocalVersionSettingFile(id); if (Files.exists(file)) try { VersionSetting versionSetting = JsonUtils.fromJsonFile(file, VersionSetting.class); initLocalVersionSetting(id, versionSetting); } catch (Exception ex) { // If [JsonParseException], [IOException] or [NullPointerException] happens, the json file is malformed and needed to be recreated. initLocalVersionSetting(id, new VersionSetting()); } } /** * Create new version setting if version id has no version setting. * * @param id the version id. * @return new version setting, null if given version does not exist. */ public VersionSetting createLocalVersionSetting(String id) { if (!hasVersion(id)) return null; if (localVersionSettings.containsKey(id)) return getLocalVersionSetting(id); else return initLocalVersionSetting(id, new VersionSetting()); } private VersionSetting initLocalVersionSetting(String id, VersionSetting vs) { localVersionSettings.put(id, vs); vs.addListener(a -> saveVersionSetting(id)); return vs; } /** * Get the version setting for version id. * * @param id version id * @return corresponding version setting, null if the version has no its own version setting. */ @Nullable public VersionSetting getLocalVersionSetting(String id) { if (!localVersionSettings.containsKey(id)) loadLocalVersionSetting(id); VersionSetting setting = localVersionSettings.get(id); if (setting != null && isModpack(id)) setting.setGameDirType(GameDirectoryType.VERSION_FOLDER); return setting; } @Nullable public VersionSetting getLocalVersionSettingOrCreate(String id) { VersionSetting vs = getLocalVersionSetting(id); if (vs == null) { vs = createLocalVersionSetting(id); } return vs; } public VersionSetting getVersionSetting(String id) { VersionSetting vs = getLocalVersionSetting(id); if (vs == null || vs.isUsesGlobal()) { profile.getGlobal().setUsesGlobal(true); return profile.getGlobal(); } else return vs; } public Optional getVersionIconFile(String id) { Path root = getVersionRoot(id); for (String extension : FXUtils.IMAGE_EXTENSIONS) { Path file = root.resolve("icon." + extension); if (Files.exists(file)) { return Optional.of(file); } } return Optional.empty(); } public void setVersionIconFile(String id, Path iconFile) throws IOException { String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT); if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) { throw new IllegalArgumentException("Unsupported icon file: " + ext); } deleteIconFile(id); FileUtils.copyFile(iconFile, getVersionRoot(id).resolve("icon." + ext)); } public void deleteIconFile(String id) { Path root = getVersionRoot(id); for (String extension : FXUtils.IMAGE_EXTENSIONS) { Path file = root.resolve("icon." + extension); try { Files.deleteIfExists(file); } catch (IOException e) { LOG.warning("Failed to delete icon file: " + file, e); } } } public Image getVersionIconImage(String id) { if (id == null || !isLoaded()) return VersionIconType.DEFAULT.getIcon(); VersionSetting vs = getLocalVersionSettingOrCreate(id); VersionIconType iconType = vs != null ? Lang.requireNonNullElse(vs.getVersionIcon(), VersionIconType.DEFAULT) : VersionIconType.DEFAULT; if (iconType == VersionIconType.DEFAULT) { Version version = getVersion(id).resolve(this); Optional iconFile = getVersionIconFile(id); if (iconFile.isPresent()) { try { return FXUtils.loadImage(iconFile.get()); } catch (Exception e) { LOG.warning("Failed to load version icon of " + id, e); } } if (LibraryAnalyzer.isModded(this, version)) { LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(version, null); if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) return VersionIconType.FABRIC.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT)) return VersionIconType.QUILT.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) return VersionIconType.LEGACY_FABRIC.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) return VersionIconType.NEO_FORGE.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE)) return VersionIconType.FORGE.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) return VersionIconType.CLEANROOM.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) return VersionIconType.CHICKEN.getIcon(); else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) return VersionIconType.OPTIFINE.getIcon(); } String gameVersion = getGameVersion(version).orElse(null); if (gameVersion != null) { GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion); if (versionNumber.isAprilFools()) { return VersionIconType.APRIL_FOOLS.getIcon(); } else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) { return VersionIconType.COMMAND.getIcon(); } else if (versionNumber instanceof GameVersionNumber.Old) { return VersionIconType.CRAFT_TABLE.getIcon(); } } return VersionIconType.GRASS.getIcon(); } else { return iconType.getIcon(); } } public void saveVersionSetting(String id) { if (!localVersionSettings.containsKey(id)) return; Path file = getLocalVersionSettingFile(id).toAbsolutePath().normalize(); try { Files.createDirectories(file.getParent()); } catch (IOException e) { LOG.warning("Failed to create directory: " + file.getParent(), e); } FileSaver.save(file, GSON.toJson(localVersionSettings.get(id))); } /** * Make version use self version settings instead of the global one. * * @param id the version id. * @return specialized version setting, null if given version does not exist. */ public VersionSetting specializeVersionSetting(String id) { VersionSetting vs = getLocalVersionSetting(id); if (vs == null) vs = createLocalVersionSetting(id); if (vs == null) return null; if (vs.isUsesGlobal()) { vs.setUsesGlobal(false); } return vs; } public void globalizeVersionSetting(String id) { VersionSetting vs = getLocalVersionSetting(id); if (vs != null) vs.setUsesGlobal(true); } public LaunchOptions.Builder getLaunchOptions(String version, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { VersionSetting vs = getVersionSetting(version); LaunchOptions.Builder builder = new LaunchOptions.Builder() .setGameDir(gameDir) .setJava(javaVersion) .setVersionType(Metadata.TITLE) .setVersionName(version) .setProfileName(Metadata.TITLE) .setGameArguments(StringUtils.tokenize(vs.getMinecraftArgs())) .setOverrideJavaArguments(StringUtils.tokenize(vs.getJavaArgs())) .setMaxMemory(vs.isNoJVMArgs() && vs.isAutoMemory() ? null : (int) (getAllocatedMemory( vs.getMaxMemory() * 1024L * 1024L, SystemInfo.getPhysicalMemoryStatus().getAvailable(), vs.isAutoMemory() ) / 1024 / 1024)) .setMinMemory(vs.getMinMemory()) .setMetaspace(Lang.toIntOrNull(vs.getPermSize())) .setEnvironmentVariables( Lang.mapOf(StringUtils.tokenize(vs.getEnvironmentVariables()) .stream() .map(it -> { int idx = it.indexOf('='); return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, ""); }) .collect(Collectors.toList()) ) ) .setWidth(vs.getWidth()) .setHeight(vs.getHeight()) .setFullscreen(vs.isFullscreen()) .setWrapper(vs.getWrapper()) .setProxyOption(getProxyOption()) .setPreLaunchCommand(vs.getPreLaunchCommand()) .setPostExitCommand(vs.getPostExitCommand()) .setNoGeneratedJVMArgs(vs.isNoJVMArgs()) .setNoGeneratedOptimizingJVMArgs(vs.isNoOptimizingJVMArgs()) .setNativesDirType(vs.getNativesDirType()) .setNativesDir(vs.getNativesDir()) .setProcessPriority(vs.getProcessPriority()) .setRenderer(vs.getRenderer()) .setEnableDebugLogOutput(vs.isEnableDebugLogOutput()) .setUseNativeGLFW(vs.isUseNativeGLFW()) .setUseNativeOpenAL(vs.isUseNativeOpenAL()) .setDaemon(!makeLaunchScript && vs.getLauncherVisibility().isDaemon()) .setJavaAgents(javaAgents) .setJavaArguments(javaArguments); if (StringUtils.isNotBlank(vs.getServerIp())) { builder.setQuickPlayOption(new QuickPlayOption.MultiPlayer(vs.getServerIp())); } Path json = getModpackConfiguration(version); if (Files.exists(json)) { try { String jsonText = Files.readString(json); ModpackConfiguration modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class); ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType()); if (provider != null) provider.injectLaunchOptions(jsonText, builder); } catch (IOException | JsonParseException e) { LOG.warning("Failed to parse modpack configuration file " + json, e); } } if (vs.isAutoMemory() && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx"))) builder.setMaxMemory(null); return builder; } @Override public Path getModpackConfiguration(String version) { return getVersionRoot(version).resolve("modpack.cfg"); } public void markVersionAsModpack(String id) { beingModpackVersions.add(id); } public void undoMark(String id) { beingModpackVersions.remove(id); } public void markVersionLaunchedAbnormally(String id) { try { Files.createFile(getVersionRoot(id).resolve(".abnormal")); } catch (IOException ignored) { } } public boolean unmarkVersionLaunchedAbnormally(String id) { Path file = getVersionRoot(id).resolve(".abnormal"); if (Files.isRegularFile(file)) { try { Files.delete(file); } catch (IOException e) { LOG.warning("Failed to delete abnormal mark file: " + file, e); } return true; } else { return false; } } private static final Gson GSON = new GsonBuilder() .setPrettyPrinting() .create(); private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}"; // These version ids are forbidden because they may conflict with modpack configuration filenames private static final Set FORBIDDEN_VERSION_IDS = new HashSet<>(Arrays.asList( "modpack", "minecraftinstance", "manifest")); public static boolean isValidVersionId(String id) { if (FORBIDDEN_VERSION_IDS.contains(id)) return false; if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && FORBIDDEN_VERSION_IDS.contains(id.toLowerCase(Locale.ROOT))) return false; return FileUtils.isNameValid(id); } /** * Returns true if the given version id conflicts with an existing version. */ public boolean versionIdConflicts(String id) { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { // on Windows, filenames are case-insensitive for (String existingId : versions.keySet()) { if (existingId.equalsIgnoreCase(id)) { return true; } } return false; } else { return versions.containsKey(id); } } public static long getAllocatedMemory(long minimum, long available, boolean auto) { if (auto) { available -= 512 * 1024 * 1024; // Reserve 512 MiB memory for off-heap memory and HMCL itself if (available <= 0) { return minimum; } final long threshold = 8L * 1024 * 1024 * 1024; // 8 GiB final long suggested = Math.min(available <= threshold ? (long) (available * 0.8) : (long) (threshold * 0.8 + (available - threshold) * 0.2), 16L * 1024 * 1024 * 1024); return Math.max(minimum, suggested); } else { return minimum; } } public static ProxyOption getProxyOption() { if (!config().hasProxy() || config().getProxyType() == null) { return ProxyOption.Default.INSTANCE; } return switch (config().getProxyType()) { case DIRECT -> ProxyOption.Direct.INSTANCE; case HTTP, SOCKS -> { String proxyHost = config().getProxyHost(); int proxyPort = config().getProxyPort(); if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) { yield ProxyOption.Default.INSTANCE; } String proxyUser = config().getProxyUser(); String proxyPass = config().getProxyPass(); if (StringUtils.isBlank(proxyUser)) { proxyUser = null; proxyPass = null; } else if (proxyPass == null) { proxyPass = ""; } if (config().getProxyType() == Proxy.Type.HTTP) { yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass); } else { yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass); } } default -> ProxyOption.Default.INSTANCE; }; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.mod.MinecraftInstanceTask; import org.jackhuang.hmcl.mod.Modpack; import org.jackhuang.hmcl.mod.ModpackConfiguration; import org.jackhuang.hmcl.mod.ModpackInstallTask; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; import java.util.List; public final class HMCLModpackInstallTask extends Task { private final Path zipFile; private final String name; private final HMCLGameRepository repository; private final DefaultDependencyManager dependency; private final Modpack modpack; private final List> dependencies = new ArrayList<>(1); private final List> dependents = new ArrayList<>(4); public HMCLModpackInstallTask(Profile profile, Path zipFile, Modpack modpack, String name) { dependency = profile.getDependency(); repository = profile.getRepository(); this.zipFile = zipFile; this.name = name; this.modpack = modpack; Path run = repository.getRunDirectory(name); Path json = repository.getModpackConfiguration(name); if (repository.hasVersion(name) && Files.notExists(json)) throw new IllegalArgumentException("Version " + name + " already exists"); dependents.add(dependency.gameBuilder().name(name).gameVersion(modpack.getGameVersion()).buildAsync()); onDone().register(event -> { if (event.isFailed()) repository.removeVersionFromDisk(name); }); ModpackConfiguration config = null; try { if (Files.exists(json)) { config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class)); if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType())) throw new IllegalArgumentException("Version " + name + " is not a HMCL modpack. Cannot update this version."); } } catch (JsonParseException | IOException ignore) { } dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config)); dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(name)).withStage("hmcl.modpack")); } @Override public List> getDependencies() { return dependencies; } @Override public List> getDependents() { return dependents; } @Override public void execute() throws Exception { String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json"); Version originalVersion = JsonUtils.GSON.fromJson(json, Version.class).setId(name).setJar(null); LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalVersion, null); Task libraryTask = Task.supplyAsync(() -> originalVersion); // reinstall libraries // libraries of Forge and OptiFine should be obtained by installation. for (LibraryAnalyzer.LibraryMark mark : analyzer) { if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId())) continue; libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion())); } dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackManifest.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.mod.ModpackManifest; import org.jackhuang.hmcl.mod.ModpackProvider; public final class HMCLModpackManifest implements ModpackManifest { public static final HMCLModpackManifest INSTANCE = new HMCLModpackManifest(); private HMCLModpackManifest() {} @Override public ModpackProvider getProvider() { return HMCLModpackProvider.INSTANCE; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2022 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.mod.MismatchedModpackTypeException; import org.jackhuang.hmcl.mod.Modpack; import org.jackhuang.hmcl.mod.ModpackProvider; import org.jackhuang.hmcl.mod.ModpackUpdateTask; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; public final class HMCLModpackProvider implements ModpackProvider { public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider(); @Override public String getName() { return "HMCL"; } @Override public Task createCompletionTask(DefaultDependencyManager dependencyManager, String version) { return null; } @Override public Task createUpdateTask(DefaultDependencyManager dependencyManager, String name, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof HMCLModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); if (!(dependencyManager.getGameRepository() instanceof HMCLGameRepository repository)) { throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository"); } Profile profile = repository.getProfile(); return new ModpackUpdateTask(dependencyManager.getGameRepository(), name, new HMCLModpackInstallTask(profile, zipFile, modpack, name)); } @Override public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) throws IOException, JsonParseException { String manifestJson = CompressingUtils.readTextZipEntry(file, "modpack.json"); Modpack manifest = JsonUtils.fromNonNullJson(manifestJson, HMCLModpack.class).setEncoding(encoding); String gameJson = CompressingUtils.readTextZipEntry(file, "minecraft/pack.json"); Version game = JsonUtils.fromNonNullJson(gameJson, Version.class); if (game.getJar() == null) if (StringUtils.isBlank(manifest.getVersion())) throw new JsonParseException("Cannot recognize the game version of modpack " + file + "."); else manifest.setManifest(HMCLModpackManifest.INSTANCE); else manifest.setManifest(HMCLModpackManifest.INSTANCE).setGameVersion(game.getJar()); return manifest; } private final static class HMCLModpack extends Modpack { @Override public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name, String iconUrl) { return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import com.jfoenix.controls.JFXButton; import javafx.stage.Stage; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDownloadException; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.MaintainTask; import org.jackhuang.hmcl.download.game.*; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.launch.*; import org.jackhuang.hmcl.mod.ModpackCompletionException; import org.jackhuang.hmcl.mod.ModpackConfiguration; import org.jackhuang.hmcl.mod.ModpackProvider; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.*; import org.jackhuang.hmcl.ui.*; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; import org.jackhuang.hmcl.ui.construct.PromptDialogPane; import org.jackhuang.hmcl.ui.construct.TaskExecutorDialogPane; import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.ResponseCodeException; import org.jackhuang.hmcl.util.platform.*; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.VersionNumber; import java.io.FileNotFoundException; import java.io.IOException; import java.lang.ref.WeakReference; import java.net.SocketTimeoutException; import java.net.URI; import java.nio.file.AccessDeniedException; import java.nio.file.Path; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import static javafx.application.Platform.runLater; import static javafx.application.Platform.setImplicitExit; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; import static org.jackhuang.hmcl.util.Lang.resolveException; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM; import static org.jackhuang.hmcl.util.platform.Platform.isCompatibleWithX86Java; public final class LauncherHelper { private final Profile profile; private Account account; private final String selectedVersion; private Path scriptFile; private final VersionSetting setting; private LauncherVisibility launcherVisibility; private boolean showLogs; private QuickPlayOption quickPlayOption; private boolean disableOfflineSkin = false; public LauncherHelper(Profile profile, Account account, String selectedVersion) { this.profile = Objects.requireNonNull(profile); this.account = Objects.requireNonNull(account); this.selectedVersion = Objects.requireNonNull(selectedVersion); this.setting = profile.getVersionSetting(selectedVersion); this.launcherVisibility = setting.getLauncherVisibility(); this.showLogs = setting.isShowLogs(); this.launchingStepsPane.setTitle(i18n("version.launch")); } private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL); public Account getAccount() { return account; } public void setAccount(Account account) { this.account = account; } public void setTestMode() { launcherVisibility = LauncherVisibility.KEEP; showLogs = true; } public void setKeep() { launcherVisibility = LauncherVisibility.KEEP; } public void setQuickPlayOption(QuickPlayOption quickPlayOption) { this.quickPlayOption = quickPlayOption; } public void setDisableOfflineSkin() { disableOfflineSkin = true; } public void launch() { FXUtils.checkFxUserThread(); LOG.info("Launching game version: " + selectedVersion); Controllers.dialog(launchingStepsPane); launch0(); } public void makeLaunchScript(Path scriptFile) { this.scriptFile = Objects.requireNonNull(scriptFile); launch(); } private void launch0() { // https://github.com/HMCL-dev/HMCL/pull/4121 PROCESSES.removeIf(it -> it.get() == null); HMCLGameRepository repository = profile.getRepository(); DefaultDependencyManager dependencyManager = profile.getDependency(); AtomicReference version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedVersion(selectedVersion))); Optional gameVersion = repository.getGameVersion(version.get()); boolean integrityCheck = repository.unmarkVersionLaunchedAbnormally(selectedVersion); CountDownLatch launchingLatch = new CountDownLatch(1); List javaAgents = new ArrayList<>(0); List javaArguments = new ArrayList<>(0); AtomicReference javaVersionRef = new AtomicReference<>(); TaskExecutor executor = checkGameState(profile, setting, version.get()) .thenComposeAsync(java -> { javaVersionRef.set(Objects.requireNonNull(java)); version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments)); if (setting.isNotCheckGame()) return null; return Task.allOf( dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck), Task.composeAsync(() -> { try { ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedVersion)); ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType()); if (provider == null) return null; else return provider.createCompletionTask(dependencyManager, selectedVersion); } catch (IOException e) { return null; } }), Task.composeAsync(() -> { Renderer renderer = setting.getRenderer(); if (renderer != Renderer.DEFAULT && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION); if (lib == null) return null; Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib); if (file.toAbsolutePath().toString().indexOf('=') >= 0) { LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader"); return null; } String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.name().toLowerCase(Locale.ROOT); if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) { return new LibraryDownloadTask(dependencyManager, file, lib) .thenRunAsync(() -> javaAgents.add(agent)); } else { javaAgents.add(agent); return null; } } else { return null; } }) ); }).withStage("launch.state.dependencies") .thenComposeAsync(() -> gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null)) .thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in")) .thenComposeAsync(authInfo -> Task.supplyAsync(() -> { LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions( selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, javaArguments, scriptFile != null); if (disableOfflineSkin) { launchOptionsBuilder.setDaemon(false); } if (quickPlayOption != null) { launchOptionsBuilder.setQuickPlayOption(quickPlayOption); } LaunchOptions launchOptions = launchOptionsBuilder.create(); LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedVersion), 10)); return new HMCLGameLauncher( repository, version.get(), authInfo, launchOptions, launcherVisibility == LauncherVisibility.CLOSE ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. : new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent()) ); }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { return Task.supplyAsync(launcher::launch); } else { return Task.supplyAsync(() -> { launcher.makeLaunchScript(scriptFile); return null; }); } }).thenAcceptAsync(process -> { // process is LaunchTask's result if (scriptFile == null) { PROCESSES.add(new WeakReference<>(process)); if (launcherVisibility == LauncherVisibility.CLOSE) Launcher.stopApplication(); else launchingStepsPane.setCancel(new TaskCancellationAction(it -> { process.stop(); it.fireEvent(new DialogCloseEvent()); })); } else { runLater(() -> { launchingStepsPane.fireEvent(new DialogCloseEvent()); Controllers.dialog(i18n("version.launch_script.success", FileUtils.getAbsolutePath(scriptFile))); }); } }).withFakeProgress( i18n("message.doing"), () -> launchingLatch.getCount() == 0, 6.95 ).withStage("launch.state.waiting_launching")) .withStagesHints( new Task.StagesHint("launch.state.java"), new Task.StagesHint("launch.state.dependencies", List.of("hmcl.install.assets", "hmcl.install.libraries", "hmcl.modpack.download")), new Task.StagesHint("launch.state.logging_in"), new Task.StagesHint("launch.state.waiting_launching")) .executor(); launchingStepsPane.setExecutor(executor, false); executor.addTaskListener(new TaskListener() { @Override public void onStop(boolean success, TaskExecutor executor) { runLater(() -> { // Check if the application has stopped // because onStop will be invoked if tasks fail when the executor service shut down. if (!Controllers.isStopped()) { launchingStepsPane.fireEvent(new DialogCloseEvent()); if (!success) { Exception ex = executor.getException(); if (ex != null && !(ex instanceof CancellationException)) { String message; if (ex instanceof ModpackCompletionException) { if (ex.getCause() instanceof FileNotFoundException) message = i18n("modpack.type.curse.not_found"); else message = i18n("modpack.type.curse.error"); } else if (ex instanceof PermissionException) { message = i18n("launch.failed.executable_permission"); } else if (ex instanceof ProcessCreationException) { message = i18n("launch.failed.creating_process") + "\n" + ex.getLocalizedMessage(); } else if (ex instanceof NotDecompressingNativesException) { message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage(); } else if (ex instanceof LibraryDownloadException) { message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().getName()) + "\n"; if (ex.getCause() instanceof ResponseCodeException) { ResponseCodeException rce = (ResponseCodeException) ex.getCause(); int responseCode = rce.getResponseCode(); String uri = rce.getUri(); if (responseCode == 404) message += i18n("download.code.404", uri); else message += i18n("download.failed", uri, responseCode); } else { message += StringUtils.getStackTrace(ex.getCause()); } } else if (ex instanceof DownloadException) { URI uri = ((DownloadException) ex).getUri(); if (ex.getCause() instanceof SocketTimeoutException) { message = i18n("install.failed.downloading.timeout", uri); } else if (ex.getCause() instanceof ResponseCodeException) { ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause(); if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) { message = i18n("download.code." + responseCodeException.getResponseCode(), uri); } else { message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause()); } } else { message = i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(ex.getCause()); } } else if (ex instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) { message = i18n("assets.index.malformed"); } else if (ex instanceof AuthlibInjectorDownloadException) { message = i18n("account.failed.injector_download_failure"); } else if (ex instanceof CharacterDeletedException) { message = i18n("account.failed.character_deleted"); } else if (ex instanceof ResponseCodeException) { ResponseCodeException rce = (ResponseCodeException) ex; int responseCode = rce.getResponseCode(); String uri = rce.getUri(); if (responseCode == 404) message = i18n("download.code.404", uri); else message = i18n("download.failed", uri, responseCode); } else if (ex instanceof CommandTooLongException) { message = i18n("launch.failed.command_too_long"); } else if (ex instanceof ExecutionPolicyLimitException) { Controllers.prompt(new PromptDialogPane.Builder(i18n("launch.failed.execution_policy"), (result, handler) -> { if (CommandBuilder.setExecutionPolicy()) { LOG.info("Set the ExecutionPolicy for the scope 'CurrentUser' to 'RemoteSigned'"); handler.resolve(); } else { LOG.warning("Failed to set ExecutionPolicy"); handler.reject(i18n("launch.failed.execution_policy.failed_to_set")); } }) .addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("launch.failed.execution_policy.hint"))) ); return; } else if (ex instanceof AccessDeniedException) { message = i18n("exception.access_denied", ((AccessDeniedException) ex).getFile()); } else { message = StringUtils.getStackTrace(ex); } Controllers.dialog(message, scriptFile == null ? i18n("launch.failed") : i18n("version.launch_script.failed"), MessageType.ERROR); } } } launchingStepsPane.setExecutor(null); }); } }); executor.start(); } private static Task checkGameState(Profile profile, VersionSetting setting, Version version) { LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(version, profile.getRepository().getGameVersion(version).orElse(null)); GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT)); Task getJavaTask = Task.supplyAsync(() -> { try { return setting.getJava(gameVersion, version); } catch (InterruptedException e) { throw new CancellationException(); } }); Task task; if (setting.isNotCheckJVM()) { task = getJavaTask.thenApplyAsync(java -> Lang.requireNonNullElse(java, JavaRuntime.getDefault())); } else if (setting.getJavaVersionType() == JavaVersionType.AUTO || setting.getJavaVersionType() == JavaVersionType.VERSION) { task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> { if (java != null) { return Task.completed(java); } // Reset invalid java version CompletableFuture future = new CompletableFuture<>(); Task result = Task.fromCompletableFuture(future); Runnable breakAction = () -> future.completeExceptionally(new CancellationException("No accepted java")); List supportedVersions = GameJavaVersion.getSupportedVersions(SYSTEM_PLATFORM); GameJavaVersion targetJavaVersion = null; if (setting.getJavaVersionType() == JavaVersionType.VERSION) { try { int targetJavaVersionMajor = Integer.parseInt(setting.getJavaVersion()); GameJavaVersion minimumJavaVersion = null; if (gameVersion.compareTo("1.12.2") == 0) { Optional cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM); if (cleanroomVersion.isPresent()) { minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get()); } } if (minimumJavaVersion == null) minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion); if (minimumJavaVersion != null && targetJavaVersionMajor < minimumJavaVersion.majorVersion()) { Controllers.dialog( i18n("launch.failed.java_version_too_low"), i18n("message.error"), MessageType.ERROR, breakAction ); return result; } targetJavaVersion = GameJavaVersion.get(targetJavaVersionMajor); } catch (NumberFormatException ignored) { } } else { if (gameVersion.compareTo("1.12.2") == 0) { Optional cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM); if (cleanroomVersion.isPresent()) { targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get()); } } if (targetJavaVersion == null) targetJavaVersion = version.getJavaVersion(); } if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) { downloadJava(targetJavaVersion, profile) .whenCompleteAsync((downloadedJava, exception) -> { if (exception == null) { future.complete(downloadedJava); } else { LOG.warning("Failed to download java", exception); Controllers.confirm(i18n("launch.failed.no_accepted_java"), i18n("message.warning"), MessageType.WARNING, () -> future.complete(JavaRuntime.getDefault()), breakAction); } }, Schedulers.javafx()); } else { Controllers.confirm(i18n("launch.failed.no_accepted_java"), i18n("message.warning"), MessageType.WARNING, () -> future.complete(JavaRuntime.getDefault()), breakAction); } return result; }); } else { task = getJavaTask.thenComposeAsync(java -> { Set violatedMandatoryConstraints = EnumSet.noneOf(JavaVersionConstraint.class); Set violatedSuggestedConstraints = EnumSet.noneOf(JavaVersionConstraint.class); if (java != null) { for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) { if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) { if (!constraint.checkJava(gameVersion, version, java, analyzer)) { if (constraint.isMandatory()) { violatedMandatoryConstraints.add(constraint); } else { violatedSuggestedConstraints.add(constraint); } } } } } CompletableFuture future = new CompletableFuture<>(); Task result = Task.fromCompletableFuture(future); Runnable breakAction = () -> future.completeExceptionally(new CancellationException("Launch operation was cancelled by user")); if (java == null || !violatedMandatoryConstraints.isEmpty()) { JavaRuntime suggestedJava = JavaManager.findSuitableJava(gameVersion, version); if (suggestedJava != null) { FXUtils.runInFX(() -> { Controllers.confirm(i18n("launch.advice.java.auto"), i18n("message.warning"), () -> { setting.setJavaAutoSelected(); future.complete(suggestedJava); }, breakAction); }); return result; } else if (java == null) { FXUtils.runInFX(() -> Controllers.dialog( i18n("launch.invalid_java"), i18n("message.error"), MessageType.ERROR, breakAction )); return result; } else { GameJavaVersion gameJavaVersion; if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) { String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM) .orElse(""); gameJavaVersion = !cleanroomVersion.isEmpty() ? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion) : GameJavaVersion.JAVA_21; } else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON)) gameJavaVersion = version.getJavaVersion(); else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) gameJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersion); else gameJavaVersion = null; if (gameJavaVersion != null) { FXUtils.runInFX(() -> downloadJava(gameJavaVersion, profile).whenCompleteAsync((downloadedJava, throwable) -> { if (throwable == null) { setting.setJavaAutoSelected(); future.complete(downloadedJava); } else { LOG.warning("Failed to download java", throwable); breakAction.run(); } }, Schedulers.javafx())); return result; } if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA_LINUX_JAVA_8)) { if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER) { FXUtils.runInFX(() -> Controllers.dialog(i18n("launch.advice.vanilla_linux_java_8"), i18n("message.error"), MessageType.ERROR, breakAction)); return result; } else { violatedMandatoryConstraints.remove(JavaVersionConstraint.VANILLA_LINUX_JAVA_8); } } if (violatedMandatoryConstraints.contains(JavaVersionConstraint.LAUNCH_WRAPPER)) { FXUtils.runInFX(() -> Controllers.dialog( i18n("launch.advice.java9") + "\n" + i18n("launch.advice.uncorrected"), i18n("message.error"), MessageType.ERROR, breakAction )); return result; } if (!violatedMandatoryConstraints.isEmpty()) { FXUtils.runInFX(() -> Controllers.dialog( i18n("launch.advice.unknown") + "\n" + violatedMandatoryConstraints, i18n("message.error"), MessageType.ERROR, breakAction )); return result; } } } List suggestions = new ArrayList<>(); if (Architecture.SYSTEM_ARCH == Architecture.X86_64 && java.getPlatform().getArchitecture() == Architecture.X86) { suggestions.add(i18n("launch.advice.different_platform")); } // 32-bit JVM cannot make use of too much memory. if (java.getBits() == Bits.BIT_32 && setting.getMaxMemory() > 1.5 * 1024) { // 1.5 * 1024 is an inaccurate number. // Actual memory limit depends on operating system and memory. suggestions.add(i18n("launch.advice.too_large_memory_for_32bit")); } for (JavaVersionConstraint violatedSuggestedConstraint : violatedSuggestedConstraints) { switch (violatedSuggestedConstraint) { case MODDED_JAVA_7: suggestions.add(i18n("launch.advice.java.modded_java_7")); break; case MODDED_JAVA_8: // Minecraft>=1.7.10+Forge accepts Java 8 if (java.getParsedVersion() < 8) suggestions.add(i18n("launch.advice.newer_java")); else suggestions.add(i18n("launch.advice.modded_java", 8, gameVersion)); break; case MODDED_JAVA_16: // Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17 String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null); if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0) suggestions.add(i18n("launch.advice.forge37_0_60")); else suggestions.add(i18n("launch.advice.modded_java", 16, gameVersion)); break; case MODDED_JAVA_17: suggestions.add(i18n("launch.advice.modded_java", 17, gameVersion)); break; case MODDED_JAVA_21: suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion)); break; case CLEANROOM: { String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse(""); if (!cleanroomVersion.isEmpty()) suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion)); else suggestions.add(i18n("launch.advice.cleanroom", 21, "")); break; } case VANILLA_JAVA_8_51: suggestions.add(i18n("launch.advice.java8_51_1_13")); break; case MODLAUNCHER_8: suggestions.add(i18n("launch.advice.modlauncher8")); break; case VANILLA_X86: if (setting.getNativesDirType() == NativesDirectoryType.VERSION_FOLDER && isCompatibleWithX86Java()) { suggestions.add(i18n("launch.advice.vanilla_x86.translation")); } break; default: suggestions.add(violatedSuggestedConstraint.name()); } } // Cannot allocate too much memory exceeding free space. long totalMemorySizeMB = (long) MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()); if (totalMemorySizeMB > 0 && totalMemorySizeMB < setting.getMaxMemory()) { suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB)); } VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE) .map(VersionNumber::asVersion) .orElse(null); // Forge 2760~2773 will crash game with LiteLoader. boolean hasForge2760 = forgeVersion != null && (forgeVersion.compareTo("1.12.2-14.23.5.2760") >= 0) && (forgeVersion.compareTo("1.12.2-14.23.5.2773") < 0); boolean hasLiteLoader = version.getLibraries().stream().anyMatch(it -> it.is("com.mumfrey", "liteloader")); if (hasForge2760 && hasLiteLoader && gameVersion.compareTo("1.12.2") == 0) { suggestions.add(i18n("launch.advice.forge2760_liteloader")); } // OptiFine 1.14.4 is not compatible with Forge 28.2.2 and later versions. boolean hasForge28_2_2 = forgeVersion != null && (forgeVersion.compareTo("1.14.4-28.2.2") >= 0); boolean hasOptiFine = version.getLibraries().stream().anyMatch(it -> it.is("optifine", "OptiFine")); if (hasForge28_2_2 && hasOptiFine && gameVersion.compareTo("1.14.4") == 0) { suggestions.add(i18n("launch.advice.forge28_2_2_optifine")); } if (suggestions.isEmpty()) { if (!future.isDone()) { future.complete(java); } } else { String message; if (suggestions.size() == 1) { message = i18n("launch.advice", suggestions.get(0)); } else { message = i18n("launch.advice.multi", suggestions.stream().map(it -> "→ " + it).collect(Collectors.joining("\n"))); } FXUtils.runInFX(() -> Controllers.confirm( message, i18n("message.warning"), MessageType.WARNING, () -> future.complete(java), breakAction)); } return result; }); } return task.withStage("launch.state.java"); } private static CompletableFuture downloadJava(GameJavaVersion javaVersion, Profile profile) { CompletableFuture future = new CompletableFuture<>(); Controllers.dialog(new MessageDialogPane.Builder( i18n("launch.advice.require_newer_java_version", javaVersion.majorVersion()), i18n("message.warning"), MessageType.QUESTION) .yesOrNo(() -> { DownloadProvider downloadProvider = profile.getDependency().getDownloadProvider(); Controllers.taskDialog(JavaManager.getDownloadJavaTask(downloadProvider, SYSTEM_PLATFORM, javaVersion) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { future.complete(result); } else { Throwable resolvedException = resolveException(exception); LOG.warning("Failed to download java", exception); if (!(resolvedException instanceof CancellationException)) { Controllers.dialog(DownloadProviders.localizeErrorMessage(resolvedException), i18n("install.failed")); } future.completeExceptionally(new CancellationException()); } }), i18n("download.java"), new TaskCancellationAction(() -> future.completeExceptionally(new CancellationException()))); }, () -> future.completeExceptionally(new CancellationException())).build()); return future; } private Task logIn(Account account) { return Task.composeAsync(() -> { try { if (disableOfflineSkin && account instanceof OfflineAccount offlineAccount) return Task.completed(offlineAccount.logInWithoutSkin()); else return Task.completed(account.logIn()); } catch (CredentialExpiredException e) { LOG.info("Credential has expired", e); return Task.completed(DialogController.logIn(account)); } catch (AuthenticationException e) { LOG.warning("Authentication failed, try skipping refresh", e); CompletableFuture> future = new CompletableFuture<>(); runInFX(() -> { JFXButton loginOfflineButton = new JFXButton(i18n("account.login.skip")); loginOfflineButton.setOnAction(event -> { try { future.complete(Task.completed(account.playOffline())); } catch (AuthenticationException e2) { future.completeExceptionally(e2); } }); JFXButton retryButton = new JFXButton(i18n("account.login.retry")); retryButton.setOnAction(event -> { future.complete(logIn(account)); }); Controllers.dialog(new MessageDialogPane.Builder(i18n("account.failed.server_disconnected"), i18n("account.failed"), MessageType.ERROR) .addAction(loginOfflineButton) .addAction(retryButton) .addCancel(() -> future.completeExceptionally(new CancellationException())) .build()); }); return Task.fromCompletableFuture(future).thenComposeAsync(task -> task); } }); } private void checkExit() { switch (launcherVisibility) { case HIDE_AND_REOPEN: runLater(() -> { Optional.ofNullable(Controllers.getStage()) .ifPresent(Stage::show); }); break; case KEEP: // No operations here break; case CLOSE: throw new Error("Never get to here"); case HIDE: runLater(() -> { // Shut down the platform when user closed log window. setImplicitExit(true); // If we use Launcher.stop(), log window will be halt immediately. Launcher.stopWithoutPlatform(); }); break; } } /** * The managed process listener. * Guarantee that one [JavaProcess], one [HMCLProcessListener]. * Because every time we launched a game, we generates a new [HMCLProcessListener] */ private final class HMCLProcessListener implements ProcessListener { private final ReentrantLock lock = new ReentrantLock(); private final HMCLGameRepository repository; private final Version version; private final LaunchOptions launchOptions; private ManagedProcess process; private volatile boolean lwjgl; private LogWindow logWindow; private final boolean detectWindow; private final CircularArrayList logs; private final CountDownLatch launchingLatch; private final String forbiddenAccessToken; private Thread submitLogThread; private LinkedBlockingQueue logBuffer; public HMCLProcessListener(HMCLGameRepository repository, Version version, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) { this.repository = repository; this.version = version; this.launchOptions = launchOptions; this.launchingLatch = launchingLatch; this.detectWindow = detectWindow; this.forbiddenAccessToken = authInfo != null ? authInfo.getAccessToken() : null; this.logs = new CircularArrayList<>(Log.getLogLines() + 1); } @Override public void setProcess(ManagedProcess process) { this.process = process; String command = new CommandBuilder().addAll(process.getCommands()).toString(); LOG.info("Launched process: " + command); String classpath = process.getClasspath(); if (classpath != null) { LOG.info("Process ClassPath: " + classpath); } if (showLogs) { CountDownLatch logWindowLatch = new CountDownLatch(1); runLater(() -> { logWindow = new LogWindow(process, logs); logWindow.show(); logWindowLatch.countDown(); }); logBuffer = new LinkedBlockingQueue<>(); submitLogThread = Lang.thread(new Runnable() { private final ArrayList currentLogs = new ArrayList<>(); private final Semaphore semaphore = new Semaphore(0); private void submitLogs() { if (currentLogs.size() == 1) { Log log = currentLogs.get(0); runLater(() -> logWindow.logLine(log)); } else { runLater(() -> { logWindow.logLines(currentLogs); semaphore.release(); }); semaphore.acquireUninterruptibly(); } currentLogs.clear(); } @Override public void run() { while (true) { try { currentLogs.add(logBuffer.take()); //noinspection BusyWait Thread.sleep(200); // Wait for more logs } catch (InterruptedException e) { break; } logBuffer.drainTo(currentLogs); submitLogs(); } do { submitLogs(); } while (logBuffer.drainTo(currentLogs) > 0); } }, "Game Log Submitter", true); try { logWindowLatch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } private void finishLaunch() { switch (launcherVisibility) { case HIDE_AND_REOPEN: runLater(() -> { // If application was stopped and execution services did not finish termination, // these codes will be executed. if (Controllers.getStage() != null) { Controllers.getStage().hide(); launchingLatch.countDown(); } }); break; case CLOSE: // Never come to here. break; case KEEP: runLater(launchingLatch::countDown); break; case HIDE: launchingLatch.countDown(); runLater(() -> { // If application was stopped and execution services did not finish termination, // these codes will be executed. if (Controllers.getStage() != null) { Controllers.getStage().close(); Controllers.shutdown(); Schedulers.shutdown(); System.gc(); } }); break; } } @Override public void onLog(String log, boolean isErrorStream) { if (isErrorStream) System.err.println(log); else System.out.println(log); log = StringUtils.parseEscapeSequence(log); if (forbiddenAccessToken != null) log = log.replace(forbiddenAccessToken, ""); Log4jLevel level = isErrorStream && !log.startsWith("[authlib-injector]") ? Log4jLevel.ERROR : null; if (showLogs) { if (level == null) level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO); logBuffer.add(new Log(log, level)); } else { lock.lock(); try { logs.addLast(new Log(log, level)); if (logs.size() > Log.getLogLines()) logs.removeFirst(); } finally { lock.unlock(); } } if (!lwjgl) { String lowerCaseLog = log.toLowerCase(Locale.ROOT); if (!detectWindow || lowerCaseLog.contains("lwjgl version") || lowerCaseLog.contains("lwjgl openal")) { lock.lock(); try { if (!lwjgl) { lwjgl = true; finishLaunch(); } } finally { lock.unlock(); } } } } @Override public void onExit(int exitCode, ExitType exitType) { if (showLogs) { logBuffer.add(new Log(String.format("[HMCL ProcessListener] Minecraft exit with code %d(0x%x), type is %s.", exitCode, exitCode, exitType), Log4jLevel.INFO)); submitLogThread.interrupt(); try { submitLogThread.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } launchingLatch.countDown(); if (exitType == ExitType.INTERRUPTED) return; // Game crashed before opening the game window. if (!lwjgl) { lock.lock(); try { if (!lwjgl) finishLaunch(); } finally { lock.unlock(); } } if (exitType != ExitType.NORMAL) { repository.markVersionLaunchedAbnormally(version.getId()); runLater(() -> new GameCrashWindow(process, exitType, repository, version, launchOptions, logs).show()); } checkExit(); } } private static final Queue> PROCESSES = new ConcurrentLinkedQueue<>(); public static int countMangedProcesses() { PROCESSES.removeIf(it -> { ManagedProcess process = it.get(); return process == null || !process.isRunning(); }); return PROCESSES.size(); } public static void stopManagedProcesses() { while (!PROCESSES.isEmpty()) Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ManagedProcess::stop); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2022 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.RemoteModRepository; import org.jackhuang.hmcl.ui.versions.ModTranslations; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import java.io.IOException; import java.nio.file.Path; import java.util.*; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public abstract class LocalizedRemoteModRepository implements RemoteModRepository { private static final int CONTAIN_CHINESE_WEIGHT = 10; private static final int INITIAL_CAPACITY = 16; protected abstract RemoteModRepository getBackedRemoteModRepository(); protected abstract SortType getBackedRemoteModRepositorySortOrder(); @Override public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException { if (!StringUtils.containsChinese(searchFilter)) { return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder); } Set englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY); int count = 0; for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) { for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) { if (englishSearchFiltersSet.contains(englishWord)) { continue; } englishSearchFiltersSet.add(englishWord); } count++; if (count >= 3) break; } RemoteMod[] searchResultArray = new RemoteMod[pageSize]; int totalPages, chineseIndex = 0, englishIndex = pageSize - 1; { SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder); for (Iterator iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) { if (chineseIndex > englishIndex) { LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?"); continue; } RemoteMod remoteMod = iterator.next(); ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug()); if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) { searchResultArray[chineseIndex++] = remoteMod; } else { searchResultArray[englishIndex--] = remoteMod; } } totalPages = searchResult.getTotalPages(); } StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator(); return new SearchResult(Stream.concat(Arrays.stream(searchResultArray, 0, chineseIndex).map(remoteMod -> { ModTranslations.Mod chineseRemoteMod = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug()); if (chineseRemoteMod == null || StringUtils.isBlank(chineseRemoteMod.getName()) || !StringUtils.containsChinese(chineseRemoteMod.getName())) { return Pair.pair(remoteMod, Integer.MAX_VALUE); } String chineseRemoteModName = chineseRemoteMod.getName(); if (searchFilter.isEmpty() || chineseRemoteModName.isEmpty()) { return Pair.pair(remoteMod, Math.max(searchFilter.length(), chineseRemoteModName.length())); } int l = levCalculator.calc(searchFilter, chineseRemoteModName); for (int i = 0; i < searchFilter.length(); i++) { if (chineseRemoteModName.indexOf(searchFilter.charAt(i)) >= 0) { l -= CONTAIN_CHINESE_WEIGHT; } } return Pair.pair(remoteMod, l); }).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), Arrays.stream(searchResultArray, englishIndex + 1, searchResultArray.length)), totalPages); } @Override public Stream getCategories() throws IOException { return getBackedRemoteModRepository().getCategories(); } @Override public Optional getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException { return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file); } @Override public RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException { return getBackedRemoteModRepository().getModById(downloadProvider, id); } @Override public RemoteMod.File getModFile(String modId, String fileId) throws IOException { return getBackedRemoteModRepository().getModFile(modId, fileId); } @Override public Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException { return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/Log.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2024 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.util.Log4jLevel; import static org.jackhuang.hmcl.setting.ConfigHolder.config; public final class Log { public static final int DEFAULT_LOG_LINES = 2000; public static int getLogLines() { Integer lines = config().getLogLines(); return lines != null && lines > 0 ? lines : DEFAULT_LOG_LINES; } private final String log; private Log4jLevel level; private boolean selected = false; public Log(String log) { this.log = log; } public Log(String log, Log4jLevel level) { this.log = log; this.level = level; } public String getLog() { return log; } public Log4jLevel getLevel() { Log4jLevel level = this.level; if (level == null) { level = Log4jLevel.guessLevel(log); if (level == null) level = Log4jLevel.INFO; this.level = level; } return level; } public boolean isSelected() { return selected; } public void setSelected(boolean selected) { this.selected = selected; } @Override public String toString() { return log; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.IOUtils; import org.jackhuang.hmcl.util.io.Zipper; import org.jackhuang.hmcl.util.logging.Logger; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.BufferedReader; import java.io.IOException; import java.io.UncheckedIOException; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.concurrent.CompletableFuture; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class LogExporter { private LogExporter() { } public static CompletableFuture exportLogs( Path zipFile, DefaultGameRepository gameRepository, String versionId, String logs, String launchScript, PathMatcher logMatcher) { Path runDirectory = gameRepository.getRunDirectory(versionId); Path baseDirectory = gameRepository.getBaseDirectory(); List versions = new ArrayList<>(); String currentVersionId = versionId; HashSet resolvedSoFar = new HashSet<>(); while (true) { if (resolvedSoFar.contains(currentVersionId)) break; resolvedSoFar.add(currentVersionId); Version currentVersion = gameRepository.getVersion(currentVersionId); versions.add(currentVersionId); if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) { currentVersionId = currentVersion.getInheritsFrom(); } else { break; } } return CompletableFuture.runAsync(() -> { try (Zipper zipper = new Zipper(zipFile, true)) { processLogs(runDirectory.resolve("liteconfig"), "*.log", "liteconfig", zipper, logMatcher); processLogs(runDirectory.resolve("logs"), "*.log", "logs", zipper, logMatcher); processLogs(runDirectory, "*.log", "runDirectory", zipper, logMatcher); processLogs(runDirectory.resolve("crash-reports"), "*.txt", "crash-reports", zipper, logMatcher); zipper.putTextFile(LOG.getLogs(), "hmcl.log"); zipper.putTextFile(logs, "minecraft.log"); zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh"); for (String id : versions) { Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json"); if (Files.exists(versionJson)) { zipper.putFile(versionJson, id + ".json"); } } } catch (IOException e) { throw new UncheckedIOException(e); } }); } private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper, PathMatcher logMatcher) { try (DirectoryStream stream = Files.newDirectoryStream(directory, fileExtension)) { for (Path file : stream) { if (Files.isRegularFile(file)) { if (logMatcher == null || logMatcher.matches(file)) { try (BufferedReader reader = IOUtils.newBufferedReaderMaybeNativeEncoding(file)) { zipper.putLines(reader.lines().map(Logger::filterForbiddenToken), file.getFileName().toString()); } catch (IOException e) { LOG.warning("Failed to read log file: " + file, e); } } } } } catch (Throwable e) { LOG.warning("Failed to find any log on " + logDirectory, e); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/ManuallyCreatedModpackException.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import java.nio.file.Path; public class ManuallyCreatedModpackException extends Exception { private final Path path; public ManuallyCreatedModpackException(Path path) { this.path = path; } public Path getPath() { return path; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/ManuallyCreatedModpackInstallTask.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.Unzipper; import java.nio.charset.Charset; import java.nio.file.FileSystem; import java.nio.file.Path; import java.nio.file.Paths; public class ManuallyCreatedModpackInstallTask extends Task { private final Profile profile; private final Path zipFile; private final Charset charset; private final String name; public ManuallyCreatedModpackInstallTask(Profile profile, Path zipFile, Charset charset, String name) { this.profile = profile; this.zipFile = zipFile; this.charset = charset; this.name = name; } @Override public void execute() throws Exception { Path subdirectory; try (FileSystem fs = CompressingUtils.readonly(zipFile).setEncoding(charset).build()) { subdirectory = ModpackHelper.findMinecraftDirectoryInManuallyCreatedModpack(zipFile.toString(), fs); } Path dest = Paths.get("externalgames").resolve(name); setResult(dest); new Unzipper(zipFile, dest) .setSubDirectory(subdirectory.toString()) .setTerminateIfSubDirectoryNotExists() .setEncoding(charset) .unzip(); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.mod.*; import org.jackhuang.hmcl.mod.curse.CurseModpackProvider; import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackManifest; import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackProvider; import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackProvider; import org.jackhuang.hmcl.mod.multimc.MultiMCComponents; import org.jackhuang.hmcl.mod.multimc.MultiMCInstanceConfiguration; import org.jackhuang.hmcl.mod.multimc.MultiMCModpackProvider; import org.jackhuang.hmcl.mod.server.ServerModpackManifest; import org.jackhuang.hmcl.mod.server.ServerModpackProvider; import org.jackhuang.hmcl.mod.server.ServerModpackRemoteInstallTask; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.setting.Profiles; import org.jackhuang.hmcl.setting.VersionSetting; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.function.ExceptionalConsumer; import org.jackhuang.hmcl.util.function.ExceptionalRunnable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Lang.toIterable; import static org.jackhuang.hmcl.util.Pair.pair; public final class ModpackHelper { private ModpackHelper() { } private static final Map providers = mapOf( pair(CurseModpackProvider.INSTANCE.getName(), CurseModpackProvider.INSTANCE), pair(McbbsModpackProvider.INSTANCE.getName(), McbbsModpackProvider.INSTANCE), pair(ModrinthModpackProvider.INSTANCE.getName(), ModrinthModpackProvider.INSTANCE), pair(MultiMCModpackProvider.INSTANCE.getName(), MultiMCModpackProvider.INSTANCE), pair(ServerModpackProvider.INSTANCE.getName(), ServerModpackProvider.INSTANCE), pair(HMCLModpackProvider.INSTANCE.getName(), HMCLModpackProvider.INSTANCE) ); static { MultiMCComponents.setImplementation(Metadata.FULL_TITLE); } @Nullable public static ModpackProvider getProviderByType(String type) { return providers.get(type); } public static boolean isFileModpackByExtension(Path file) { String ext = FileUtils.getExtension(file); return "zip".equals(ext) || "mrpack".equals(ext); } public static Modpack readModpackManifest(Path file, Charset charset) throws UnsupportedModpackException, ManuallyCreatedModpackException { try (ZipArchiveReader zipFile = CompressingUtils.openZipFile(file, charset)) { // Order for trying detecting manifest is necessary here. // Do not change to iterating providers. for (ModpackProvider provider : new ModpackProvider[]{ McbbsModpackProvider.INSTANCE, CurseModpackProvider.INSTANCE, ModrinthModpackProvider.INSTANCE, HMCLModpackProvider.INSTANCE, MultiMCModpackProvider.INSTANCE, ServerModpackProvider.INSTANCE}) { try { return provider.readManifest(zipFile, file, charset); } catch (Exception ignored) { } } } catch (IOException ignored) { } try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(file, charset)) { findMinecraftDirectoryInManuallyCreatedModpack(file.toString(), fs); throw new ManuallyCreatedModpackException(file); } catch (IOException e) { // ignore it } throw new UnsupportedModpackException(file.toString()); } public static Path findMinecraftDirectoryInManuallyCreatedModpack(String modpackName, FileSystem fs) throws IOException, UnsupportedModpackException { Path root = fs.getPath("/"); if (isMinecraftDirectory(root)) return root; try (Stream firstLayer = Files.list(root)) { for (Path dir : toIterable(firstLayer)) { if (isMinecraftDirectory(dir)) return dir; try (Stream secondLayer = Files.list(dir)) { for (Path subdir : toIterable(secondLayer)) { if (isMinecraftDirectory(subdir)) return subdir; } } catch (IOException ignored) { } } } catch (IOException ignored) { } throw new UnsupportedModpackException(modpackName); } private static boolean isMinecraftDirectory(Path path) { return Files.isDirectory(path.resolve("versions")) && (path.getFileName() == null || ".minecraft".equals(FileUtils.getName(path))); } public static ModpackConfiguration readModpackConfiguration(Path file) throws IOException { try { return JsonUtils.fromJsonFile(file, ModpackConfiguration.class); } catch (JsonParseException e) { throw new IOException("Malformed modpack configuration"); } } public static Task getInstallTask(Profile profile, ServerModpackManifest manifest, String name, Modpack modpack) { profile.getRepository().markVersionAsModpack(name); ExceptionalRunnable success = () -> { HMCLGameRepository repository = profile.getRepository(); repository.refreshVersions(); VersionSetting vs = repository.specializeVersionSetting(name); repository.undoMark(name); if (vs != null) vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); }; ExceptionalConsumer failure = ex -> { if (ex instanceof ModpackCompletionException && !(ex.getCause() instanceof FileNotFoundException)) { success.run(); // This is tolerable and we will not delete the game } }; return new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name) .whenComplete(Schedulers.defaultScheduler(), success, failure) .withStagesHints("hmcl.modpack", "hmcl.modpack.download"); } public static boolean isExternalGameNameConflicts(String name) { return Files.exists(Paths.get("externalgames").resolve(name)); } public static Task getInstallManuallyCreatedModpackTask(Profile profile, Path zipFile, String name, Charset charset) { if (isExternalGameNameConflicts(name)) { throw new IllegalArgumentException("name existing"); } return new ManuallyCreatedModpackInstallTask(profile, zipFile, charset, name) .thenAcceptAsync(Schedulers.javafx(), location -> { Profile newProfile = new Profile(name, location); newProfile.setUseRelativePath(true); Profiles.getProfiles().add(newProfile); Profiles.setSelectedProfile(newProfile); }); } public static Task getInstallTask(Profile profile, Path zipFile, String name, Modpack modpack, String iconUrl) { profile.getRepository().markVersionAsModpack(name); ExceptionalRunnable success = () -> { HMCLGameRepository repository = profile.getRepository(); repository.refreshVersions(); VersionSetting vs = repository.specializeVersionSetting(name); repository.undoMark(name); if (vs != null) vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); }; ExceptionalConsumer failure = ex -> { if (ex instanceof ModpackCompletionException && !(ex.getCause() instanceof FileNotFoundException)) { success.run(); // This is tolerable and we will not delete the game } }; if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl) .whenComplete(Schedulers.defaultScheduler(), success, failure) .thenComposeAsync(createMultiMCPostInstallTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name)) .withStagesHints("hmcl.modpack", "hmcl.modpack.download"); else if (modpack.getManifest() instanceof McbbsModpackManifest) return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl) .whenComplete(Schedulers.defaultScheduler(), success, failure) .thenComposeAsync(createMcbbsPostInstallTask(profile, (McbbsModpackManifest) modpack.getManifest(), name)) .withStagesHints("hmcl.modpack", "hmcl.modpack.download"); else return modpack.getInstallTask(profile.getDependency(), zipFile, name, iconUrl) .whenComplete(Schedulers.javafx(), success, failure) .withStagesHints("hmcl.modpack", "hmcl.modpack.download"); } public static Task getUpdateTask(Profile profile, ServerModpackManifest manifest, Charset charset, String name, ModpackConfiguration configuration) throws UnsupportedModpackException { switch (configuration.getType()) { case ServerModpackRemoteInstallTask.MODPACK_TYPE: return new ModpackUpdateTask(profile.getRepository(), name, new ServerModpackRemoteInstallTask(profile.getDependency(), manifest, name)) .thenComposeAsync(profile.getRepository().refreshVersionsAsync()) .withStagesHints("hmcl.modpack", "hmcl.modpack.download"); default: throw new UnsupportedModpackException(); } } public static Task getUpdateTask(Profile profile, Path zipFile, Charset charset, String name, ModpackConfiguration configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException { Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset); ModpackProvider provider = getProviderByType(configuration.getType()); if (provider == null) { throw new UnsupportedModpackException(); } if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack) .thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name)) .thenComposeAsync(profile.getRepository().refreshVersionsAsync()); else return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack) .thenComposeAsync(profile.getRepository().refreshVersionsAsync()); } public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) { vs.setUsesGlobal(false); vs.setGameDirType(GameDirectoryType.VERSION_FOLDER); if (c.isOverrideJavaLocation()) { vs.setJavaDir(Lang.nonNull(c.getJavaPath(), "")); } if (c.isOverrideMemory()) { vs.setPermSize(Optional.ofNullable(c.getPermGen()).map(Object::toString).orElse("")); if (c.getMaxMemory() != null) vs.setMaxMemory(c.getMaxMemory()); vs.setMinMemory(c.getMinMemory()); } if (c.isOverrideCommands()) { vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), "")); vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), "")); } if (c.isOverrideJavaArgs()) { vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), "")); } if (c.isOverrideConsole()) { vs.setShowLogs(c.isShowConsole()); } if (c.isOverrideWindow()) { vs.setFullscreen(c.isFullscreen()); if (c.getWidth() != null) vs.setWidth(c.getWidth()); if (c.getHeight() != null) vs.setHeight(c.getHeight()); } } private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) { if (c.isOverrideCommands()) { vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), "")); vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), "")); } if (c.isOverrideJavaArgs()) { vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), "")); } } private static Task createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) { return Task.runAsync(Schedulers.javafx(), () -> { VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); ModpackHelper.applyCommandAndJvmSettings(manifest, vs); }); } private static Task createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) { return Task.runAsync(Schedulers.javafx(), () -> { VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); ModpackHelper.toVersionSetting(manifest, vs); }); } private static Task createMcbbsPostInstallTask(Profile profile, McbbsModpackManifest manifest, String version) { return Task.runAsync(Schedulers.javafx(), () -> { VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version)); if (manifest.getLaunchInfo().getMinMemory() > vs.getMaxMemory()) vs.setMaxMemory(manifest.getLaunchInfo().getMinMemory()); }); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/OAuthServer.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import fi.iki.elonen.NanoHTTPD; import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.OAuth; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.IOUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.io.IOException; import java.security.SecureRandom; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class OAuthServer extends NanoHTTPD implements OAuth.Session { private final int port; private final CompletableFuture future = new CompletableFuture<>(); private final String codeVerifier; private final String state; public static String lastlyOpenedURL; private String idToken; private OAuthServer(int port) { super(port); this.port = port; var encoder = Base64.getUrlEncoder().withoutPadding(); var random = new SecureRandom(); { // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 // https://datatracker.ietf.org/doc/html/rfc6749#section-10.12 byte[] bytes = new byte[32]; random.nextBytes(bytes); this.state = encoder.encodeToString(bytes); } { // https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 byte[] bytes = new byte[64]; random.nextBytes(bytes); this.codeVerifier = encoder.encodeToString(bytes); } } @Override public String getCodeVerifier() { return codeVerifier; } @Override public String getState() { return state; } @Override public String getRedirectURI() { return String.format("http://localhost:%d/auth-response", port); } @Override public String waitFor() throws InterruptedException, ExecutionException { return future.get(); } @Override public String getIdToken() { return idToken; } @Override public Response serve(IHTTPSession session) { if (!"/auth-response".equals(session.getUri())) { return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, ""); } if (session.getMethod() == Method.POST) { Map files = new HashMap<>(); try { session.parseBody(files); } catch (IOException e) { LOG.warning("Failed to read post data", e); return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, ""); } catch (ResponseException re) { return newFixedLengthResponse(re.getStatus(), MIME_PLAINTEXT, re.getMessage()); } } else if (session.getMethod() == Method.GET) { // do nothing } else { return newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, ""); } String parameters = session.getQueryParameterString(); Map query = mapOf(NetworkUtils.parseQuery(parameters)); String code = query.get("code"); if (code != null) { if (this.state.equals(query.get("state"))) { idToken = query.get("id_token"); future.complete(code); } else if (query.containsKey("state")) { LOG.warning("Failed to authenticate: invalid state in parameters"); future.completeExceptionally(new AuthenticationException("Failed to authenticate: invalid state")); } else { LOG.warning("Failed to authenticate: missing state in parameters"); future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing state")); } } else { LOG.warning("Failed to authenticate: missing authorization code in parameters"); future.completeExceptionally(new AuthenticationException("Failed to authenticate: missing authorization code")); } String html; try { html = IOUtils.readFullyAsString(OAuthServer.class.getResourceAsStream("/assets/microsoft_auth.html")) .replace("%style%", Themes.getTheme().toColorScheme().toStyleSheet().replace("-monet", "--monet")) .replace("%lang%", Locale.getDefault().toLanguageTag()) .replace("%success%", i18n("message.success")) .replace("%ok%", i18n("button.ok")) .replace("%close_page%", i18n("account.methods.microsoft.close_page")); } catch (IOException e) { LOG.error("Failed to load html", e); return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_HTML, ""); } thread(() -> { try { Thread.sleep(1000); stop(); } catch (InterruptedException e) { LOG.error("Failed to sleep for 1 second"); } }); return newFixedLengthResponse(Response.Status.OK, "text/html; charset=UTF-8", html); } public static class Factory implements OAuth.Callback { public final EventManager onGrantDeviceCode = new EventManager<>(); public final EventManager onOpenBrowserAuthorizationCode = new EventManager<>(); public final EventManager onOpenBrowserDevice = new EventManager<>(); @Override public OAuth.Session startServer() throws IOException, AuthenticationException { if (StringUtils.isBlank(getClientId())) { throw new MicrosoftAuthenticationNotSupportedException(); } IOException exception = null; for (int port : new int[]{29111, 29112, 29113, 29114, 29115}) { try { OAuthServer server = new OAuthServer(port); server.start(NanoHTTPD.SOCKET_READ_TIMEOUT, true); return server; } catch (IOException e) { exception = e; } } throw exception; } @Override public void grantDeviceCode(String userCode, String verificationURI) { onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI)); } @Override public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException { lastlyOpenedURL = url; switch (grantFlow) { case AUTHORIZATION_CODE -> onOpenBrowserAuthorizationCode.fireEvent(new OpenBrowserEvent(this, url)); case DEVICE -> onOpenBrowserDevice.fireEvent(new OpenBrowserEvent(this, url)); } } @Override public String getClientId() { return System.getProperty("hmcl.microsoft.auth.id", JarUtils.getAttribute("hmcl.microsoft.auth.id", "")); } } public static class GrantDeviceCodeEvent extends Event { private final String userCode; private final String verificationUri; public GrantDeviceCodeEvent(Object source, String userCode, String verificationUri) { super(source); this.userCode = userCode; this.verificationUri = verificationUri; } public String getUserCode() { return userCode; } public String getVerificationUri() { return verificationUri; } } public static class OpenBrowserEvent extends Event { private final String url; public OpenBrowserEvent(Object source, String url) { super(source); this.url = url; } public String getUrl() { return url; } } public static class MicrosoftAuthenticationNotSupportedException extends AuthenticationException { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/game/TexturesLoader.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.game; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.beans.binding.ObjectBinding; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.image.Image; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.ServerResponseMalformedException; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.offline.Skin; import org.jackhuang.hmcl.auth.yggdrasil.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.Holder; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.io.IOException; import java.io.InputStream; import java.lang.ref.WeakReference; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import static java.util.Collections.emptyMap; import static java.util.Collections.singletonMap; import static java.util.Objects.requireNonNull; import static org.jackhuang.hmcl.util.Lang.threadPool; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author yushijinhun */ public final class TexturesLoader { private TexturesLoader() { } // ==== Texture Loading ==== public static class LoadedTexture { private final Image image; private final Map metadata; public LoadedTexture(Image image, Map metadata) { this.image = requireNonNull(image); this.metadata = requireNonNull(metadata); } public Image getImage() { return image; } public Map getMetadata() { return metadata; } } private static final ThreadPoolExecutor POOL = threadPool("TexturesDownload", true, 2, 10, TimeUnit.SECONDS); private static final Path TEXTURES_DIR = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("skins"); private static Path getTexturePath(Texture texture) { String url = texture.getUrl(); int slash = url.lastIndexOf('/'); int dot = url.lastIndexOf('.'); if (dot < slash) { dot = url.length(); } String hash = url.substring(slash + 1, dot); String prefix = hash.length() > 2 ? hash.substring(0, 2) : "xx"; return TEXTURES_DIR.resolve(prefix).resolve(hash); } public static LoadedTexture loadTexture(Texture texture) throws Throwable { if (StringUtils.isBlank(texture.getUrl())) { throw new IOException("Texture url is empty"); } Path file = getTexturePath(texture); if (!Files.isRegularFile(file)) { // download it try { new FileDownloadTask(texture.getUrl(), file).run(); LOG.info("Texture downloaded: " + texture.getUrl()); } catch (Exception e) { if (Files.isRegularFile(file)) { // concurrency conflict? LOG.warning("Failed to download texture " + texture.getUrl() + ", but the file is available", e); } else { throw new IOException("Failed to download texture " + texture.getUrl()); } } } Image img; try (InputStream in = Files.newInputStream(file)) { img = new Image(in); } if (img.isError()) throw img.getException(); Map metadata = texture.getMetadata(); if (metadata == null) { metadata = emptyMap(); } return new LoadedTexture(img, metadata); } // ==== // ==== Skins ==== private static final String[] DEFAULT_SKINS = {"alex", "ari", "efe", "kai", "makena", "noor", "steve", "sunny", "zuri"}; public static Image getDefaultSkinImage() { return FXUtils.newBuiltinImage("/assets/img/skin/wide/steve.png"); } public static LoadedTexture getDefaultSkin(UUID uuid) { int idx = Math.floorMod(uuid.hashCode(), DEFAULT_SKINS.length * 2); TextureModel model; Image skin; if (idx < DEFAULT_SKINS.length) { model = TextureModel.SLIM; skin = FXUtils.newBuiltinImage("/assets/img/skin/slim/" + DEFAULT_SKINS[idx] + ".png"); } else { model = TextureModel.WIDE; skin = FXUtils.newBuiltinImage("/assets/img/skin/wide/" + DEFAULT_SKINS[idx - DEFAULT_SKINS.length] + ".png"); } return new LoadedTexture(skin, singletonMap("model", model.modelName)); } public static TextureModel getDefaultModel(UUID uuid) { return TextureModel.WIDE.modelName.equals(getDefaultSkin(uuid).getMetadata().get("model")) ? TextureModel.WIDE : TextureModel.SLIM; } public static ObjectBinding skinBinding(YggdrasilService service, UUID uuid) { LoadedTexture uuidFallback = getDefaultSkin(uuid); return BindingMapping.of(service.getProfileRepository().binding(uuid)) .map(profile -> profile .flatMap(it -> { try { return YggdrasilService.getTextures(it); } catch (ServerResponseMalformedException e) { LOG.warning("Failed to parse texture payload", e); return Optional.empty(); } }) .flatMap(it -> Optional.ofNullable(it.get(TextureType.SKIN))) .filter(it -> StringUtils.isNotBlank(it.getUrl()))) .asyncMap(it -> { if (it.isPresent()) { Texture texture = it.get(); return CompletableFuture.supplyAsync(() -> { try { return loadTexture(texture); } catch (Throwable e) { LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e); return uuidFallback; } }, POOL); } else { return CompletableFuture.completedFuture(uuidFallback); } }, uuidFallback); } public static ObservableValue skinBinding(Account account) { LoadedTexture uuidFallback = getDefaultSkin(account.getUUID()); if (account instanceof OfflineAccount) { OfflineAccount offlineAccount = (OfflineAccount) account; SimpleObjectProperty binding = new SimpleObjectProperty<>(); InvalidationListener listener = o -> { Skin skin = offlineAccount.getSkin(); String username = offlineAccount.getUsername(); binding.set(uuidFallback); if (skin != null) { skin.load(username).setExecutor(POOL).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception != null) { LOG.warning("Failed to load texture", exception); } else if (result != null && result.getSkin() != null && result.getSkin().getImage() != null) { Map metadata; if (result.getModel() != null) { metadata = singletonMap("model", result.getModel().modelName); } else { metadata = emptyMap(); } binding.set(new LoadedTexture(result.getSkin().getImage(), metadata)); } }).start(); } }; listener.invalidated(offlineAccount); binding.addListener(new Holder<>(listener)); offlineAccount.addListener(new WeakInvalidationListener(listener)); return binding; } else { return BindingMapping.of(account.getTextures()) .asyncMap(textures -> { if (textures.isPresent()) { Texture texture = textures.get().get(TextureType.SKIN); if (texture != null && StringUtils.isNotBlank(texture.getUrl())) { return CompletableFuture.supplyAsync(() -> { try { return loadTexture(texture); } catch (Throwable e) { LOG.warning("Failed to load texture " + texture.getUrl() + ", using fallback texture", e); return uuidFallback; } }, POOL); } } return CompletableFuture.completedFuture(uuidFallback); }, uuidFallback); } } // ==== // ==== Avatar ==== public static void drawAvatar(Canvas canvas, Image skin) { GraphicsContext g = canvas.getGraphicsContext2D(); g.clearRect(0, 0, canvas.getWidth(), canvas.getHeight()); int size = (int) canvas.getWidth(); int scale = (int) skin.getWidth() / 64; int faceOffset = (int) Math.round(size / 18.0); g.setImageSmoothing(false); drawAvatar(g, skin, size, scale, faceOffset); } private static void drawAvatar(GraphicsContext g, Image skin, int size, int scale, int faceOffset) { g.drawImage(skin, 8 * scale, 8 * scale, 8 * scale, 8 * scale, faceOffset, faceOffset, size - 2 * faceOffset, size - 2 * faceOffset); g.drawImage(skin, 40 * scale, 8 * scale, 8 * scale, 8 * scale, 0, 0, size, size); } private static final class SkinBindingChangeListener implements ChangeListener { static final WeakHashMap hole = new WeakHashMap<>(); final WeakReference canvasRef; final ObservableValue binding; SkinBindingChangeListener(Canvas canvas, ObservableValue binding) { this.canvasRef = new WeakReference<>(canvas); this.binding = binding; } @Override public void changed(ObservableValue observable, LoadedTexture oldValue, LoadedTexture loadedTexture) { Canvas canvas = canvasRef.get(); if (canvas != null) drawAvatar(canvas, loadedTexture.image); } } public static void fxAvatarBinding(Canvas canvas, ObservableValue skinBinding) { synchronized (SkinBindingChangeListener.hole) { SkinBindingChangeListener oldListener = SkinBindingChangeListener.hole.remove(canvas); if (oldListener != null) oldListener.binding.removeListener(oldListener); SkinBindingChangeListener listener = new SkinBindingChangeListener(canvas, skinBinding); listener.changed(skinBinding, null, skinBinding.getValue()); skinBinding.addListener(listener); SkinBindingChangeListener.hole.put(canvas, listener); } } public static void bindAvatar(Canvas canvas, YggdrasilService service, UUID uuid) { fxAvatarBinding(canvas, skinBinding(service, uuid)); } public static void bindAvatar(Canvas canvas, Account account) { if (account instanceof YggdrasilAccount || account instanceof MicrosoftAccount || account instanceof OfflineAccount) fxAvatarBinding(canvas, skinBinding(account)); else { unbindAvatar(canvas); drawAvatar(canvas, getDefaultSkin(account.getUUID()).image); } } public static void unbindAvatar(Canvas canvas) { synchronized (SkinBindingChangeListener.hole) { SkinBindingChangeListener oldListener = SkinBindingChangeListener.hole.remove(canvas); if (oldListener != null) oldListener.binding.removeListener(oldListener); } } // ==== } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/java/HMCLJavaRepository.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2024 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.java; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask; import org.jackhuang.hmcl.download.java.mojang.MojangJavaRemoteFiles; import org.jackhuang.hmcl.game.DownloadInfo; import org.jackhuang.hmcl.game.GameJavaVersion; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class HMCLJavaRepository implements JavaRepository { public static final String MOJANG_JAVA_PREFIX = "mojang-"; private final Path root; public HMCLJavaRepository(Path root) { this.root = root; } public Path getPlatformRoot(Platform platform) { return root.resolve(platform.toString()); } @Override public Path getJavaDir(Platform platform, String name) { return getPlatformRoot(platform).resolve(name); } public Path getJavaDir(Platform platform, GameJavaVersion gameJavaVersion) { return getJavaDir(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component()); } @Override public Path getManifestFile(Platform platform, String name) { return getPlatformRoot(platform).resolve(name + ".json"); } public Path getManifestFile(Platform platform, GameJavaVersion gameJavaVersion) { return getManifestFile(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component()); } public boolean isInstalled(Platform platform, String name) { return Files.exists(getManifestFile(platform, name)); } public boolean isInstalled(Platform platform, GameJavaVersion gameJavaVersion) { return isInstalled(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component()); } public @Nullable Path getJavaExecutable(Platform platform, String name) { Path javaDir = getJavaDir(platform, name); try { return JavaManager.getExecutable(javaDir).toRealPath(); } catch (IOException ignored) { if (platform.getOperatingSystem() == OperatingSystem.MACOS) { try { return JavaManager.getMacExecutable(javaDir).toRealPath(); } catch (IOException ignored1) { } } } return null; } public @Nullable Path getJavaExecutable(Platform platform, GameJavaVersion gameJavaVersion) { return getJavaExecutable(platform, MOJANG_JAVA_PREFIX + gameJavaVersion.component()); } private static void getAllJava(List list, Platform platform, Path platformRoot, boolean isManaged) { try (DirectoryStream stream = Files.newDirectoryStream(platformRoot)) { for (Path file : stream) { try { String name = file.getFileName().toString(); if (name.endsWith(".json") && Files.isRegularFile(file)) { Path javaDir = file.resolveSibling(name.substring(0, name.length() - ".json".length())); Path executable; try { executable = JavaManager.getExecutable(javaDir).toRealPath(); } catch (IOException e) { if (platform.getOperatingSystem() == OperatingSystem.MACOS) executable = JavaManager.getMacExecutable(javaDir).toRealPath(); else throw e; } if (Files.isDirectory(javaDir)) { JavaManifest manifest = JsonUtils.fromJsonFile(file, JavaManifest.class); list.add(JavaRuntime.of(executable, manifest.getInfo(), isManaged)); } } } catch (Throwable e) { LOG.warning("Failed to parse " + file, e); } } } catch (IOException ignored) { } } @Override public Collection getAllJava(Platform platform) { Path platformRoot = getPlatformRoot(platform); if (!Files.isDirectory(platformRoot)) return Collections.emptyList(); ArrayList list = new ArrayList<>(); getAllJava(list, platform, platformRoot, true); if (platform.getOperatingSystem() == OperatingSystem.MACOS) { platformRoot = root.resolve(platform.getOperatingSystem().getMojangName() + "-" + platform.getArchitecture().getCheckedName()); if (Files.isDirectory(platformRoot)) getAllJava(list, platform, platformRoot, false); } return list; } @Override public Task getDownloadJavaTask(DownloadProvider downloadProvider, Platform platform, GameJavaVersion gameJavaVersion) { Path javaDir = getJavaDir(platform, gameJavaVersion); return new MojangJavaDownloadTask(downloadProvider, javaDir, gameJavaVersion, JavaManager.getMojangJavaPlatform(platform)).thenApplyAsync(result -> { Path executable; try { executable = JavaManager.getExecutable(javaDir).toRealPath(); } catch (IOException e) { if (platform.getOperatingSystem() == OperatingSystem.MACOS) executable = JavaManager.getMacExecutable(javaDir).toRealPath(); else throw e; } JavaInfo info; if (JavaManager.isCompatible(platform)) info = JavaInfoUtils.fromExecutable(executable, false); else info = new JavaInfo(platform, result.download.getVersion().getName(), null); Map update = new LinkedHashMap<>(); update.put("provider", "mojang"); update.put("component", gameJavaVersion.component()); Map files = new LinkedHashMap<>(); result.remoteFiles.getFiles().forEach((path, file) -> { if (file instanceof MojangJavaRemoteFiles.RemoteFile) { DownloadInfo downloadInfo = ((MojangJavaRemoteFiles.RemoteFile) file).getDownloads().get("raw"); if (downloadInfo != null) { files.put(path, new JavaLocalFiles.LocalFile(downloadInfo.getSha1(), downloadInfo.getSize())); } } else if (file instanceof MojangJavaRemoteFiles.RemoteDirectory) { files.put(path, new JavaLocalFiles.LocalDirectory()); } else if (file instanceof MojangJavaRemoteFiles.RemoteLink) { files.put(path, new JavaLocalFiles.LocalLink(((MojangJavaRemoteFiles.RemoteLink) file).getTarget())); } }); JavaManifest manifest = new JavaManifest(info, update, files); JsonUtils.writeToJsonFile(getManifestFile(platform, gameJavaVersion), manifest); return JavaRuntime.of(executable, info, true); }); } public Task getInstallJavaTask(Platform platform, String name, Map update, Path archiveFile) { Path javaDir = getJavaDir(platform, name); return new JavaInstallTask(javaDir, update, archiveFile).thenApplyAsync(result -> { if (!result.getInfo().getPlatform().equals(platform)) throw new IOException("Platform is mismatch: expected " + platform + " but got " + result.getInfo().getPlatform()); Path executable = javaDir.resolve("bin").resolve(platform.getOperatingSystem().getJavaExecutable()).toRealPath(); JsonUtils.writeToJsonFile(getManifestFile(platform, name), result); return JavaRuntime.of(executable, result.getInfo(), true); }); } @Override public Task getUninstallJavaTask(Platform platform, String name) { return Task.runAsync(() -> { Files.deleteIfExists(getManifestFile(platform, name)); FileUtils.deleteDirectory(getJavaDir(platform, name)); }); } @Override public Task getUninstallJavaTask(JavaRuntime java) { return Task.runAsync(() -> { Path root = getPlatformRoot(java.getPlatform()); Path relativized = root.relativize(java.getBinary()); if (relativized.getNameCount() > 1) { String name = relativized.getName(0).toString(); Files.deleteIfExists(getManifestFile(java.getPlatform(), name)); FileUtils.deleteDirectory(getJavaDir(java.getPlatform(), name)); } }); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/java/JavaInfoUtils.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.java; import com.google.gson.annotations.SerializedName; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.platform.SystemUtils; import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; /** * @author Glavo * @see Glavo/java-info */ public final class JavaInfoUtils { private JavaInfoUtils() { } private static Path tryFindReleaseFile(Path executable) { Path parent = executable.getParent(); if (parent != null && parent.getFileName() != null && parent.getFileName().toString().equals("bin")) { Path javaHome = parent.getParent(); if (javaHome != null && javaHome.getFileName() != null) { Path releaseFile = javaHome.resolve("release"); String javaHomeName = javaHome.getFileName().toString(); if ((javaHomeName.contains("jre") || javaHomeName.contains("jdk") || javaHomeName.contains("openj9")) && Files.isRegularFile(releaseFile)) { return releaseFile; } } } return null; } public static @NotNull JavaInfo fromExecutable(Path executable, boolean tryFindReleaseFile) throws IOException { assert executable.isAbsolute(); Path releaseFile; if (tryFindReleaseFile && (releaseFile = tryFindReleaseFile(executable)) != null) { try { return JavaInfo.fromReleaseFile(releaseFile); } catch (IOException ignored) { } } Path thisPath = JarUtils.thisJarPath(); if (thisPath == null) { throw new IOException("Failed to find current HMCL location"); } try { Result result = JsonUtils.GSON.fromJson(SystemUtils.run( executable.toString(), "-classpath", thisPath.toString(), org.glavo.info.Main.class.getName() ), Result.class); if (result == null) { throw new IOException("Failed to get Java info from " + executable); } if (result.javaVersion == null) { throw new IOException("Failed to get Java version from " + executable); } Architecture architecture = Architecture.parseArchName(result.osArch); Platform platform = Platform.getPlatform(OperatingSystem.CURRENT_OS, architecture != Architecture.UNKNOWN ? architecture : Architecture.SYSTEM_ARCH); return new JavaInfo(platform, result.javaVersion, result.javaVendor); } catch (IOException e) { throw e; } catch (Throwable e) { throw new IOException(e); } } private static final class Result { @SerializedName("os.name") public String osName; @SerializedName("os.arch") public String osArch; @SerializedName("java.version") public String javaVersion; @SerializedName("java.vendor") public String javaVendor; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/java/JavaInstallTask.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2024 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.java; import kala.compress.archivers.ArchiveEntry; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.IOUtils; import org.jackhuang.hmcl.util.tree.ArchiveFileTree; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; import java.util.ArrayList; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.Map; /** * @author Glavo */ public final class JavaInstallTask extends Task { private final Path targetDir; private final Map update; private final Path archiveFile; private final Map files = new LinkedHashMap<>(); private final ArrayList nameStack = new ArrayList<>(); private final byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE]; private final MessageDigest messageDigest = DigestUtils.getDigest("SHA-1"); public JavaInstallTask(Path targetDir, Map update, Path archiveFile) { this.targetDir = targetDir; this.update = update; this.archiveFile = archiveFile; } @Override public void execute() throws Exception { JavaInfo info; try (ArchiveFileTree tree = ArchiveFileTree.open(archiveFile)) { info = JavaInfo.fromArchive(tree); copyDirContent(tree, targetDir); } setResult(new JavaManifest(info, update, files)); } private void copyDirContent(ArchiveFileTree tree, Path targetDir) throws IOException { copyDirContent(tree, tree.getRoot().getSubDirs().values().iterator().next(), targetDir); } private void copyDirContent(ArchiveFileTree tree, ArchiveFileTree.Dir dir, Path targetDir) throws IOException { Files.createDirectories(targetDir); for (Map.Entry pair : dir.getFiles().entrySet()) { Path path = targetDir.resolve(pair.getKey()); E entry = pair.getValue(); nameStack.add(pair.getKey()); if (tree.isLink(entry)) { String linkTarget = tree.getLink(entry); files.put(String.join("/", nameStack), new JavaLocalFiles.LocalLink(linkTarget)); Files.createSymbolicLink(path, Paths.get(linkTarget)); } else { long size = 0L; try (InputStream input = tree.getInputStream(entry); OutputStream output = Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { messageDigest.reset(); int c; while ((c = input.read(buffer)) > 0) { size += c; output.write(buffer, 0, c); messageDigest.update(buffer, 0, c); } } if (tree.isExecutable(entry)) FileUtils.setExecutable(path); files.put(String.join("/", nameStack), new JavaLocalFiles.LocalFile(HexFormat.of().formatHex(messageDigest.digest()), size)); } nameStack.remove(nameStack.size() - 1); } for (Map.Entry> pair : dir.getSubDirs().entrySet()) { nameStack.add(pair.getKey()); files.put(String.join("/", nameStack), new JavaLocalFiles.LocalDirectory()); copyDirContent(tree, pair.getValue(), targetDir.resolve(pair.getKey())); nameStack.remove(nameStack.size() - 1); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/java/JavaLocalFiles.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.java; import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import java.lang.reflect.Type; /** * @author Glavo */ public final class JavaLocalFiles { @JsonAdapter(Serializer.class) public abstract static class Local { private final String type; Local(String type) { this.type = type; } public String getType() { return type; } } public static final class LocalFile extends Local { private final String sha1; private final long size; public LocalFile(String sha1, long size) { super("file"); this.sha1 = sha1; this.size = size; } public String getSha1() { return sha1; } public long getSize() { return size; } } public static final class LocalDirectory extends Local { public LocalDirectory() { super("directory"); } } public static final class LocalLink extends Local { private final String target; public LocalLink(String target) { super("link"); this.target = target; } public String getTarget() { return target; } } public static class Serializer implements JsonSerializer, JsonDeserializer { @Override public JsonElement serialize(Local src, Type typeOfSrc, JsonSerializationContext context) { JsonObject obj = new JsonObject(); obj.addProperty("type", src.getType()); if (src instanceof LocalFile) { obj.addProperty("sha1", ((LocalFile) src).getSha1()); obj.addProperty("size", ((LocalFile) src).getSize()); } else if (src instanceof LocalLink) { obj.addProperty("target", ((LocalLink) src).getTarget()); } return obj; } @Override public Local deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (!json.isJsonObject()) throw new JsonParseException(json.toString()); JsonObject obj = json.getAsJsonObject(); if (!obj.has("type")) throw new JsonParseException(json.toString()); String type = obj.getAsJsonPrimitive("type").getAsString(); try { switch (type) { case "file": { String sha1 = obj.getAsJsonPrimitive("sha1").getAsString(); long size = obj.getAsJsonPrimitive("size").getAsLong(); return new LocalFile(sha1, size); } case "directory": { return new LocalDirectory(); } case "link": { String target = obj.getAsJsonPrimitive("target").getAsString(); return new LocalLink(target); } default: throw new AssertionError("unknown type: " + type); } } catch (Throwable e) { throw new JsonParseException(json.toString()); } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2024 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.java; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.GameJavaVersion; import org.jackhuang.hmcl.game.JavaVersionConstraint; import org.jackhuang.hmcl.game.Version; import org.jackhuang.hmcl.setting.ConfigHolder; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.CacheRepository; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.*; import org.jackhuang.hmcl.util.platform.windows.WinReg; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.nio.file.*; import java.util.*; import java.util.concurrent.CountDownLatch; import java.util.stream.Collectors; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class JavaManager { private JavaManager() { } public static final HMCLJavaRepository REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("java")); public static final HMCLJavaRepository LOCAL_REPOSITORY = new HMCLJavaRepository(Metadata.HMCL_CURRENT_DIRECTORY.resolve("java")); public static String getMojangJavaPlatform(Platform platform) { if (platform.getOperatingSystem() == OperatingSystem.WINDOWS) { if (Architecture.SYSTEM_ARCH == Architecture.X86) { return "windows-x86"; } else if (Architecture.SYSTEM_ARCH == Architecture.X86_64) { return "windows-x64"; } else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { return "windows-arm64"; } } else if (platform.getOperatingSystem() == OperatingSystem.LINUX) { if (Architecture.SYSTEM_ARCH == Architecture.X86) { return "linux-i386"; } else if (Architecture.SYSTEM_ARCH == Architecture.X86_64) { return "linux"; } } else if (platform.getOperatingSystem() == OperatingSystem.MACOS) { if (Architecture.SYSTEM_ARCH == Architecture.X86_64) { return "mac-os"; } else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { return "mac-os-arm64"; } } return null; } public static Path getExecutable(Path javaHome) { return javaHome.resolve("bin").resolve(OperatingSystem.CURRENT_OS.getJavaExecutable()); } public static Path getMacExecutable(Path javaHome) { return javaHome.resolve("jre.bundle/Contents/Home/bin/java"); } public static boolean isCompatible(Platform platform) { if (platform.getOperatingSystem() != OperatingSystem.CURRENT_OS) return false; Architecture architecture = platform.getArchitecture(); if (architecture == Architecture.SYSTEM_ARCH || architecture == Architecture.CURRENT_ARCH) return true; switch (OperatingSystem.CURRENT_OS) { case WINDOWS: if (Architecture.SYSTEM_ARCH == Architecture.X86_64) return architecture == Architecture.X86; if (Architecture.SYSTEM_ARCH == Architecture.ARM64) return OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277 && architecture == Architecture.X86_64 || architecture == Architecture.X86; break; case LINUX: if (Architecture.SYSTEM_ARCH == Architecture.X86_64) return architecture == Architecture.X86; break; case MACOS: if (Architecture.SYSTEM_ARCH == Architecture.ARM64) return architecture == Architecture.X86_64; break; } return false; } private static volatile Map allJava; private static final CountDownLatch LATCH = new CountDownLatch(1); private static final ObjectProperty> allJavaProperty = new SimpleObjectProperty<>(); private static Map getAllJavaMap() throws InterruptedException { Map map = allJava; if (map == null) { LATCH.await(); map = allJava; } return map; } private static void updateAllJavaProperty(Map javaRuntimes) { JavaRuntime[] array = javaRuntimes.values().toArray(new JavaRuntime[0]); Arrays.sort(array); allJavaProperty.set(Arrays.asList(array)); } public static boolean isInitialized() { return allJava != null; } public static Collection getAllJava() throws InterruptedException { return getAllJavaMap().values(); } public static ObjectProperty> getAllJavaProperty() { return allJavaProperty; } public static JavaRuntime getJava(Path executable) throws IOException, InterruptedException { executable = executable.toRealPath(); JavaRuntime javaRuntime = getAllJavaMap().get(executable); if (javaRuntime != null) { return javaRuntime; } JavaInfo info = JavaInfoUtils.fromExecutable(executable, true); return JavaRuntime.of(executable, info, false); } public static void refresh() { Task.supplyAsync(JavaManager::searchPotentialJavaExecutables).whenComplete(Schedulers.javafx(), (result, exception) -> { if (result != null) { LATCH.await(); allJava = result; updateAllJavaProperty(result); } }).start(); } public static Task getAddJavaTask(Path binary) { return Task.supplyAsync("Get Java", () -> JavaManager.getJava(binary)) .thenApplyAsync(Schedulers.javafx(), javaRuntime -> { if (!JavaManager.isCompatible(javaRuntime.getPlatform())) { throw new UnsupportedPlatformException("Incompatible platform: " + javaRuntime.getPlatform()); } String pathString = javaRuntime.getBinary().toString(); ConfigHolder.globalConfig().getDisabledJava().remove(pathString); if (ConfigHolder.globalConfig().getUserJava().add(pathString)) { addJava(javaRuntime); } return javaRuntime; }); } public static Task getDownloadJavaTask(DownloadProvider downloadProvider, Platform platform, GameJavaVersion gameJavaVersion) { return REPOSITORY.getDownloadJavaTask(downloadProvider, platform, gameJavaVersion) .thenApplyAsync(Schedulers.javafx(), java -> { addJava(java); return java; }); } public static Task getInstallJavaTask(Platform platform, String name, Map update, Path archiveFile) { return REPOSITORY.getInstallJavaTask(platform, name, update, archiveFile) .thenApplyAsync(Schedulers.javafx(), java -> { addJava(java); return java; }); } public static Task getUninstallJavaTask(JavaRuntime java) { assert java.isManaged(); Path platformRoot; try { platformRoot = REPOSITORY.getPlatformRoot(java.getPlatform()).toRealPath(); } catch (Throwable ignored) { return Task.completed(null); } if (!java.getBinary().startsWith(platformRoot)) return Task.completed(null); Path relativized = platformRoot.relativize(java.getBinary()); if (relativized.getNameCount() > 1) { FXUtils.runInFX(() -> { try { removeJava(java); } catch (InterruptedException e) { throw new AssertionError("Unreachable code", e); } }); String name = relativized.getName(0).toString(); return REPOSITORY.getUninstallJavaTask(java.getPlatform(), name); } else { return Task.completed(null); } } // FXThread public static void addJava(JavaRuntime java) throws InterruptedException { Map oldMap = getAllJavaMap(); if (!oldMap.containsKey(java.getBinary())) { HashMap newMap = new HashMap<>(oldMap); newMap.put(java.getBinary(), java); allJava = newMap; updateAllJavaProperty(newMap); } } // FXThread public static void removeJava(JavaRuntime java) throws InterruptedException { removeJava(java.getBinary()); } // FXThread public static void removeJava(Path realPath) throws InterruptedException { Map oldMap = getAllJavaMap(); if (oldMap.containsKey(realPath)) { HashMap newMap = new HashMap<>(oldMap); newMap.remove(realPath); allJava = newMap; updateAllJavaProperty(newMap); } } private static JavaRuntime chooseJava(@Nullable JavaRuntime java1, JavaRuntime java2) { if (java1 == null) return java2; if (java1.getParsedVersion() != java2.getParsedVersion()) // Prefer the Java version that is closer to the game's recommended Java version return java1.getParsedVersion() < java2.getParsedVersion() ? java1 : java2; else return java1.getVersionNumber().compareTo(java2.getVersionNumber()) >= 0 ? java1 : java2; } @Nullable public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, Version version) throws InterruptedException { return findSuitableJava(getAllJava(), gameVersion, version); } @Nullable public static JavaRuntime findSuitableJava(Collection javaRuntimes, GameVersionNumber gameVersion, Version version) { LibraryAnalyzer analyzer = version != null ? LibraryAnalyzer.analyze(version, gameVersion != null ? gameVersion.toString() : null) : null; boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64 && (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) && (gameVersion == null || gameVersion.compareTo("1.6") < 0); JavaRuntime mandatory = null; JavaRuntime suggested = null; for (JavaRuntime java : javaRuntimes) { if (forceX86) { if (!java.getArchitecture().isX86()) continue; } else { if (java.getArchitecture() != Architecture.SYSTEM_ARCH) continue; } boolean violationMandatory = false; boolean violationSuggested = false; for (JavaVersionConstraint constraint : JavaVersionConstraint.ALL) { if (constraint.appliesToVersion(gameVersion, version, java, analyzer)) { if (!constraint.checkJava(gameVersion, version, java, analyzer)) { if (constraint.isMandatory()) { violationMandatory = true; } else { violationSuggested = true; } } } } if (!violationMandatory) { mandatory = chooseJava(mandatory, java); if (!violationSuggested) suggested = chooseJava(suggested, java); } } return suggested != null ? suggested : mandatory; } public static void initialize() { Map allJava = searchPotentialJavaExecutables(); JavaManager.allJava = allJava; LATCH.countDown(); FXUtils.runInFX(() -> updateAllJavaProperty(allJava)); } // search java private static Map searchPotentialJavaExecutables() { Map javaRuntimes = new HashMap<>(); searchAllJavaInRepository(javaRuntimes, Platform.SYSTEM_PLATFORM); switch (OperatingSystem.CURRENT_OS) { case WINDOWS: if (Architecture.SYSTEM_ARCH == Architecture.X86_64) searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86); if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86_64); searchAllJavaInRepository(javaRuntimes, Platform.WINDOWS_X86); } break; case MACOS: if (Architecture.SYSTEM_ARCH == Architecture.ARM64) searchAllJavaInRepository(javaRuntimes, Platform.MACOS_X86_64); break; } switch (OperatingSystem.CURRENT_OS) { case WINDOWS: queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Runtime Environment"); queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\Java Development Kit"); queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JRE"); queryJavaInRegistryKey(javaRuntimes, WinReg.HKEY.HKEY_LOCAL_MACHINE, "SOFTWARE\\JavaSoft\\JDK"); searchJavaInProgramFiles(javaRuntimes, "ProgramFiles", "C:\\Program Files"); searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(x86)", "C:\\Program Files (x86)"); if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { searchJavaInProgramFiles(javaRuntimes, "ProgramFiles(ARM)", "C:\\Program Files (ARM)"); } break; case LINUX: searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/java")); // Oracle RPMs searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib/jvm")); // General locations searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib32/jvm")); // General locations searchAllJavaInDirectory(javaRuntimes, Paths.get("/usr/lib64/jvm")); // General locations searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), "/.sdkman/candidates/java")); // SDKMAN! break; case MACOS: searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get("/Library/Java/JavaVirtualMachines")); searchJavaInMacJavaVirtualMachines(javaRuntimes, Paths.get(System.getProperty("user.home"), "/Library/Java/JavaVirtualMachines")); tryAddJavaExecutable(javaRuntimes, Paths.get("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home/bin/java")); tryAddJavaExecutable(javaRuntimes, Paths.get("/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/MacOS/itms/java/bin/java")); // Homebrew tryAddJavaExecutable(javaRuntimes, Paths.get("/opt/homebrew/opt/java/bin/java")); searchAllJavaInDirectory(javaRuntimes, Paths.get("/opt/homebrew/Cellar/openjdk")); try (DirectoryStream dirs = Files.newDirectoryStream(Paths.get("/opt/homebrew/Cellar"), "openjdk@*")) { for (Path dir : dirs) { searchAllJavaInDirectory(javaRuntimes, dir); } } catch (IOException e) { LOG.warning("Failed to get subdirectories of /opt/homebrew/Cellar"); } break; default: break; } // Search Minecraft bundled runtimes if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH.isX86()) { FileUtils.tryGetPath(System.getenv("localappdata"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local\\runtime") .ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false)); FileUtils.tryGetPath(Lang.requireNonNullElse(System.getenv("ProgramFiles(x86)"), "C:\\Program Files (x86)"), "Minecraft Launcher\\runtime") .ifPresent(it -> searchAllOfficialJava(javaRuntimes, it, false)); } else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64) { searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), ".minecraft/runtime"), false); } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { searchAllOfficialJava(javaRuntimes, Paths.get(System.getProperty("user.home"), "Library/Application Support/minecraft/runtime"), false); } searchAllOfficialJava(javaRuntimes, CacheRepository.getInstance().getCacheDirectory().resolve("java"), true); // Search in PATH. if (System.getenv("PATH") != null) { String[] paths = System.getenv("PATH").split(File.pathSeparator); for (String path : paths) { // https://github.com/HMCL-dev/HMCL/issues/4079 // https://github.com/Meloong-Git/PCL/issues/4261 if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && path.toLowerCase(Locale.ROOT) .contains("\\common files\\oracle\\java\\")) { continue; } try { tryAddJavaExecutable(javaRuntimes, Path.of(path, OperatingSystem.CURRENT_OS.getJavaExecutable())); } catch (InvalidPathException ignored) { } } } if (System.getenv("HMCL_JRES") != null) { String[] paths = System.getenv("HMCL_JRES").split(File.pathSeparator); for (String path : paths) { try { tryAddJavaHome(javaRuntimes, Paths.get(path)); } catch (InvalidPathException ignored) { } } } searchAllJavaInDirectory(javaRuntimes, Paths.get(System.getProperty("user.home"), ".jdks")); for (String javaPath : ConfigHolder.globalConfig().getUserJava()) { try { tryAddJavaExecutable(javaRuntimes, Paths.get(javaPath)); } catch (InvalidPathException e) { LOG.warning("Invalid Java path: " + javaPath); } } JavaRuntime currentJava = JavaRuntime.CURRENT_JAVA; if (currentJava != null && !javaRuntimes.containsKey(currentJava.getBinary()) && !ConfigHolder.globalConfig().getDisabledJava().contains(currentJava.getBinary().toString())) { javaRuntimes.put(currentJava.getBinary(), currentJava); } LOG.trace(javaRuntimes.values().stream().sorted() .map(it -> String.format(" - %s %s (%s, %s): %s", it.isJDK() ? "JDK" : "JRE", it.getVersion(), it.getPlatform().getArchitecture().getDisplayName(), Lang.requireNonNullElse(it.getVendor(), "Unknown"), it.getBinary())) .collect(Collectors.joining("\n", "Finished Java lookup, found " + javaRuntimes.size() + "\n", ""))); return javaRuntimes; } private static void tryAddJavaHome(Map javaRuntimes, Path javaHome) { Path executable = getExecutable(javaHome); if (!Files.isRegularFile(executable)) { return; } try { executable = executable.toRealPath(); } catch (IOException e) { LOG.warning("Failed to resolve path " + executable, e); return; } if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) { return; } JavaInfo info = null; Path releaseFile = javaHome.resolve("release"); if (Files.exists(releaseFile)) { try { info = JavaInfo.fromReleaseFile(releaseFile); } catch (IOException e) { LOG.warning("Failed to read release file " + releaseFile, e); } } if (info == null) { try { info = JavaInfoUtils.fromExecutable(executable, false); } catch (IOException e) { LOG.warning("Failed to lookup Java executable at " + executable, e); } } if (info != null && isCompatible(info.getPlatform())) javaRuntimes.put(executable, JavaRuntime.of(executable, info, false)); } private static void tryAddJavaExecutable(Map javaRuntimes, Path executable) { try { executable = executable.toRealPath(); } catch (IOException e) { return; } if (javaRuntimes.containsKey(executable) || ConfigHolder.globalConfig().getDisabledJava().contains(executable.toString())) { return; } JavaInfo info = null; try { info = JavaInfoUtils.fromExecutable(executable, true); } catch (IOException e) { LOG.warning("Failed to lookup Java executable at " + executable, e); } if (info != null && isCompatible(info.getPlatform())) { javaRuntimes.put(executable, JavaRuntime.of(executable, info, false)); } } private static void tryAddJavaInComponentDir(Map javaRuntimes, String platform, Path component, boolean verify) { Path sha1File = component.resolve(platform).resolve(component.getFileName() + ".sha1"); if (!Files.isRegularFile(sha1File)) return; Path dir = component.resolve(platform).resolve(component.getFileName()); if (verify) { try (BufferedReader reader = Files.newBufferedReader(sha1File)) { String line; while ((line = reader.readLine()) != null) { if (line.isEmpty()) continue; int idx = line.indexOf(" /#//"); if (idx <= 0) throw new IOException("Illegal line: " + line); Path file = dir.resolve(line.substring(0, idx)); // Should we check the sha1 of files? This will take a lot of time. if (Files.notExists(file)) throw new NoSuchFileException(file.toAbsolutePath().toString()); } } catch (IOException e) { LOG.warning("Failed to verify Java in " + component, e); return; } } if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { Path macPath = dir.resolve("jre.bundle/Contents/Home"); if (Files.exists(macPath)) { tryAddJavaHome(javaRuntimes, macPath); return; } else LOG.warning("The Java is not in 'jre.bundle/Contents/Home'"); } tryAddJavaHome(javaRuntimes, dir); } private static void searchAllJavaInRepository(Map javaRuntimes, Platform platform) { for (JavaRuntime java : REPOSITORY.getAllJava(platform)) { javaRuntimes.put(java.getBinary(), java); } for (JavaRuntime java : LOCAL_REPOSITORY.getAllJava(platform)) { javaRuntimes.put(java.getBinary(), java); } } private static void searchAllOfficialJava(Map javaRuntimes, Path directory, boolean verify) { if (!Files.isDirectory(directory)) return; // Examples: // $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home // $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta String javaPlatform = getMojangJavaPlatform(Platform.SYSTEM_PLATFORM); if (javaPlatform != null) { searchAllOfficialJava(javaRuntimes, directory, javaPlatform, verify); } if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { if (Architecture.SYSTEM_ARCH == Architecture.X86_64) { searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify); } else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) { if (OperatingSystem.SYSTEM_BUILD_NUMBER >= 21277) { searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86_64), verify); } searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.WINDOWS_X86), verify); } } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.CURRENT_ARCH == Architecture.ARM64) { searchAllOfficialJava(javaRuntimes, directory, getMojangJavaPlatform(Platform.MACOS_X86_64), verify); } } private static void searchAllOfficialJava(Map javaRuntimes, Path directory, String platform, boolean verify) { try (DirectoryStream dir = Files.newDirectoryStream(directory)) { // component can be jre-legacy, java-runtime-alpha, java-runtime-beta, java-runtime-gamma or any other being added in the future. for (Path component : dir) { tryAddJavaInComponentDir(javaRuntimes, platform, component, verify); } } catch (IOException e) { LOG.warning("Failed to list java-runtime directory " + directory, e); } } private static void searchAllJavaInDirectory(Map javaRuntimes, Path directory) { if (!Files.isDirectory(directory)) { return; } try (DirectoryStream stream = Files.newDirectoryStream(directory)) { for (Path subDir : stream) { tryAddJavaHome(javaRuntimes, subDir); } } catch (IOException e) { LOG.warning("Failed to find Java in " + directory, e); } } private static void searchJavaInProgramFiles(Map javaRuntimes, String env, String defaultValue) { String programFiles = Lang.requireNonNullElse(System.getenv(env), defaultValue); Path path; try { path = Paths.get(programFiles); } catch (InvalidPathException ignored) { return; } for (String vendor : new String[]{"Java", "BellSoft", "AdoptOpenJDK", "Zulu", "Microsoft", "Eclipse Foundation", "Semeru"}) { searchAllJavaInDirectory(javaRuntimes, path.resolve(vendor)); } } private static void searchJavaInMacJavaVirtualMachines(Map javaRuntimes, Path directory) { if (!Files.isDirectory(directory)) { return; } try (DirectoryStream stream = Files.newDirectoryStream(directory)) { for (Path subDir : stream) { tryAddJavaHome(javaRuntimes, subDir.resolve("Contents/Home")); } } catch (IOException e) { LOG.warning("Failed to find Java in " + directory, e); } } // ==== Windows Registry Support ==== private static void queryJavaInRegistryKey(Map javaRuntimes, WinReg.HKEY hkey, String location) { WinReg reg = WinReg.INSTANCE; if (reg == null) return; for (String java : reg.querySubKeys(hkey, location)) { if (!reg.querySubKeys(hkey, java).contains(java + "\\MSI")) continue; Object home = reg.queryValue(hkey, java, "JavaHome"); if (home instanceof String) { try { tryAddJavaHome(javaRuntimes, Paths.get((String) home)); } catch (InvalidPathException e) { LOG.warning("Invalid Java path in system registry: " + home); } } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManifest.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2024 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.java; import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.Nullable; import java.lang.reflect.Type; import java.util.Map; import java.util.Optional; import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf; /** * @author Glavo */ @JsonAdapter(JavaManifest.Serializer.class) public final class JavaManifest { private final JavaInfo info; @Nullable private final Map update; @Nullable private final Map files; public JavaManifest(JavaInfo info, @Nullable Map update, @Nullable Map files) { this.info = info; this.update = update; this.files = files; } public JavaInfo getInfo() { return info; } public Map getUpdate() { return update; } public Map getFiles() { return files; } public static final class Serializer implements JsonSerializer, JsonDeserializer { private static final Type LOCAL_FILES_TYPE = mapTypeOf(String.class, JavaLocalFiles.Local.class).getType(); @Override public JsonElement serialize(JavaManifest src, Type typeOfSrc, JsonSerializationContext context) { JsonObject res = new JsonObject(); res.addProperty("os.name", src.getInfo().getPlatform().getOperatingSystem().getCheckedName()); res.addProperty("os.arch", src.getInfo().getPlatform().getArchitecture().getCheckedName()); res.addProperty("java.version", src.getInfo().getVersion()); res.addProperty("java.vendor", src.getInfo().getVendor()); if (src.getUpdate() != null) res.add("update", context.serialize(src.getUpdate())); if (src.getFiles() != null) res.add("files", context.serialize(src.getFiles(), LOCAL_FILES_TYPE)); return res; } @Override public JavaManifest deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (!json.isJsonObject()) throw new JsonParseException(json.toString()); try { JsonObject jsonObject = json.getAsJsonObject(); OperatingSystem osName = OperatingSystem.parseOSName(jsonObject.getAsJsonPrimitive("os.name").getAsString()); Architecture osArch = Architecture.parseArchName(jsonObject.getAsJsonPrimitive("os.arch").getAsString()); String javaVersion = jsonObject.getAsJsonPrimitive("java.version").getAsString(); String javaVendor = Optional.ofNullable(jsonObject.get("java.vendor")).map(JsonElement::getAsString).orElse(null); Map update = jsonObject.has("update") ? context.deserialize(jsonObject.get("update"), Map.class) : null; Map files = jsonObject.has("files") ? context.deserialize(jsonObject.get("files"), LOCAL_FILES_TYPE) : null; if (osName == null || osArch == null || javaVersion == null) throw new JsonParseException(json.toString()); return new JavaManifest(new JavaInfo(Platform.getPlatform(osName, osArch), javaVersion, javaVendor), update, files); } catch (JsonParseException e) { throw e; } catch (Throwable e) { throw new JsonParseException(e); } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/Accounts.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.auth.authlibinjector.*; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory; import org.jackhuang.hmcl.auth.microsoft.MicrosoftService; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory; import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException; import org.jackhuang.hmcl.game.OAuthServer; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.skin.InvalidSkinException; import javax.net.ssl.SSLException; import java.io.IOException; import java.io.Reader; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; 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.setting.ConfigHolder.globalConfig; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; import static org.jackhuang.hmcl.util.Lang.immutableListOf; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf; import static org.jackhuang.hmcl.util.gson.JsonUtils.mapTypeOf; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author huangyuhui */ public final class Accounts { private Accounts() { } private static final AuthlibInjectorArtifactProvider AUTHLIB_INJECTOR_DOWNLOADER = createAuthlibInjectorArtifactProvider(); public static final OAuthServer.Factory OAUTH_CALLBACK = new OAuthServer.Factory(); public static final OfflineAccountFactory FACTORY_OFFLINE = new OfflineAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER); public static final AuthlibInjectorAccountFactory FACTORY_AUTHLIB_INJECTOR = new AuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, Accounts::getOrCreateAuthlibInjectorServer); public static final MicrosoftAccountFactory FACTORY_MICROSOFT = new MicrosoftAccountFactory(new MicrosoftService(OAUTH_CALLBACK)); public static final List> FACTORIES = immutableListOf(FACTORY_OFFLINE, FACTORY_MICROSOFT, FACTORY_AUTHLIB_INJECTOR); // ==== login type / account factory mapping ==== private static final Map> type2factory = new HashMap<>(); private static final Map, String> factory2type = new HashMap<>(); static { type2factory.put("offline", FACTORY_OFFLINE); type2factory.put("authlibInjector", FACTORY_AUTHLIB_INJECTOR); type2factory.put("microsoft", FACTORY_MICROSOFT); type2factory.forEach((type, factory) -> factory2type.put(factory, type)); } public static String getLoginType(AccountFactory factory) { String type = factory2type.get(factory); if (type != null) return type; if (factory instanceof BoundAuthlibInjectorAccountFactory) { return factory2type.get(FACTORY_AUTHLIB_INJECTOR); } throw new IllegalArgumentException("Unrecognized account factory"); } public static AccountFactory getAccountFactory(String loginType) { return Optional.ofNullable(type2factory.get(loginType)) .orElseThrow(() -> new IllegalArgumentException("Unrecognized login type")); } public static BoundAuthlibInjectorAccountFactory getAccountFactoryByAuthlibInjectorServer(AuthlibInjectorServer server) { return new BoundAuthlibInjectorAccountFactory(AUTHLIB_INJECTOR_DOWNLOADER, server); } // ==== public static AccountFactory getAccountFactory(Account account) { if (account instanceof OfflineAccount) return FACTORY_OFFLINE; else if (account instanceof AuthlibInjectorAccount) return FACTORY_AUTHLIB_INJECTOR; else if (account instanceof MicrosoftAccount) return FACTORY_MICROSOFT; else throw new IllegalArgumentException("Failed to determine account type: " + account); } private static final String GLOBAL_PREFIX = "$GLOBAL:"; private static final ObservableList> globalAccountStorages = FXCollections.observableArrayList(); private static final ObservableList accounts = observableArrayList(account -> new Observable[]{account}); private static final ObjectProperty selectedAccount = new SimpleObjectProperty<>(Accounts.class, "selectedAccount"); /** * True if {@link #init()} hasn't been called. */ private static boolean initialized = false; private static Map getAccountStorage(Account account) { Map storage = account.toStorage(); storage.put("type", getLoginType(getAccountFactory(account))); return storage; } 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 ArrayList> global = new ArrayList<>(); ArrayList> portable = new ArrayList<>(); for (Account account : accounts) { Map storage = getAccountStorage(account); if (account.isPortable()) portable.add(storage); else global.add(storage); } if (!global.equals(globalAccountStorages)) globalAccountStorages.setAll(global); if (!portable.equals(config().getAccountStorages())) config().getAccountStorages().setAll(portable); } private static void loadGlobalAccountStorages() { Path globalAccountsFile = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("accounts.json"); if (Files.exists(globalAccountsFile)) { try (Reader reader = Files.newBufferedReader(globalAccountsFile)) { globalAccountStorages.setAll(Config.CONFIG_GSON.fromJson(reader, listTypeOf(mapTypeOf(Object.class, Object.class)))); } catch (Throwable e) { LOG.warning("Failed to load global accounts", e); } } globalAccountStorages.addListener(onInvalidating(() -> FileSaver.save(globalAccountsFile, Config.CONFIG_GSON.toJson(globalAccountStorages)))); } private static Account parseAccount(Map storage) { AccountFactory factory = type2factory.get(storage.get("type")); if (factory == null) { LOG.warning("Unrecognized account type: " + storage); return null; } try { return factory.fromStorage(storage); } catch (Exception e) { LOG.warning("Failed to load account: " + storage, e); return null; } } /** * Called when it's ready to load accounts from {@link ConfigHolder#config()}. */ static void init() { if (initialized) throw new IllegalStateException("Already initialized"); if (!config().isAddedLittleSkin()) { AuthlibInjectorServer littleSkin = new AuthlibInjectorServer("https://littleskin.cn/api/yggdrasil/"); if (config().getAuthlibInjectorServers().stream().noneMatch(it -> littleSkin.getUrl().equals(it.getUrl()))) { config().getAuthlibInjectorServers().add(0, littleSkin); } config().setAddedLittleSkin(true); } loadGlobalAccountStorages(); // load accounts Account selected = null; for (Map storage : config().getAccountStorages()) { Account account = parseAccount(storage); if (account != null) { account.setPortable(true); accounts.add(account); if (Boolean.TRUE.equals(storage.get("selected"))) { selected = account; } } } for (Map storage : globalAccountStorages) { Account account = parseAccount(storage); if (account != null) { accounts.add(account); } } String selectedAccountIdentifier = config().getSelectedAccount(); if (selected == null && selectedAccountIdentifier != null) { boolean portable = true; if (selectedAccountIdentifier.startsWith(GLOBAL_PREFIX)) { portable = false; selectedAccountIdentifier = selectedAccountIdentifier.substring(GLOBAL_PREFIX.length()); } for (Account account : accounts) { if (selectedAccountIdentifier.equals(account.getIdentifier())) { if (portable == account.isPortable()) { selected = account; break; } else if (selected == null) { selected = account; } } } } if (selected == null && !accounts.isEmpty()) { selected = accounts.get(0); } if (!globalConfig().isEnableOfflineAccount()) for (Account account : accounts) { if (account instanceof MicrosoftAccount) { globalConfig().setEnableOfflineAccount(true); break; } } if (!globalConfig().isEnableOfflineAccount()) accounts.addListener(new ListChangeListener() { @Override public void onChanged(Change change) { while (change.next()) { for (Account account : change.getAddedSubList()) { if (account instanceof MicrosoftAccount) { accounts.removeListener(this); globalConfig().setEnableOfflineAccount(true); return; } } } } }); selectedAccount.set(selected); InvalidationListener listener = o -> { // this method 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 account = selectedAccount.get(); if (accounts.isEmpty()) { if (account == null) { // valid } else { // the previously selected account is gone, we can only set it to null here selectedAccount.set(null); } } else { if (accounts.contains(account)) { // valid } else { // the previously selected account is gone selectedAccount.set(accounts.get(0)); } } }; selectedAccount.addListener(listener); selectedAccount.addListener(onInvalidating(() -> { Account account = selectedAccount.get(); if (account != null) config().setSelectedAccount(account.isPortable() ? account.getIdentifier() : GLOBAL_PREFIX + account.getIdentifier()); else config().setSelectedAccount(null); })); accounts.addListener(listener); accounts.addListener(onInvalidating(Accounts::updateAccountStorages)); initialized = true; config().getAuthlibInjectorServers().addListener(onInvalidating(Accounts::removeDanglingAuthlibInjectorAccounts)); if (selected != null) { Account finalSelected = selected; Schedulers.io().execute(() -> { try { finalSelected.logIn(); } catch (Throwable e) { LOG.warning("Failed to log " + finalSelected + " in", e); } }); } for (AuthlibInjectorServer server : config().getAuthlibInjectorServers()) { if (selected instanceof AuthlibInjectorAccount && ((AuthlibInjectorAccount) selected).getServer() == server) continue; Schedulers.io().execute(() -> { try { server.fetchMetadataResponse(); } catch (IOException e) { LOG.warning("Failed to fetch authlib-injector server metadata: " + server, e); } }); } } public static ObservableList getAccounts() { return accounts; } 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 AuthlibInjectorArtifactProvider createAuthlibInjectorArtifactProvider() { String authlibinjectorLocation = System.getProperty("hmcl.authlibinjector.location"); if (authlibinjectorLocation != null) { LOG.info("Using specified authlib-injector: " + authlibinjectorLocation); return new SimpleAuthlibInjectorArtifactProvider(Paths.get(authlibinjectorLocation)); } String authlibInjectorVersion = JarUtils.getAttribute("hmcl.authlib-injector.version", null); if (authlibInjectorVersion == null) throw new AssertionError("Missing hmcl.authlib-injector.version"); String authlibInjectorFileName = "authlib-injector-" + authlibInjectorVersion + ".jar"; return new AuthlibInjectorExtractor(Accounts.class.getResource("/assets/" + authlibInjectorFileName), Metadata.DEPENDENCIES_DIRECTORY.resolve("universal").resolve(authlibInjectorFileName)); } private static AuthlibInjectorServer getOrCreateAuthlibInjectorServer(String url) { return config().getAuthlibInjectorServers().stream() .filter(server -> url.equals(server.getUrl())) .findFirst() .orElseGet(() -> { AuthlibInjectorServer server = new AuthlibInjectorServer(url); config().getAuthlibInjectorServers().add(server); 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 final Map, String> unlocalizedLoginTypeNames = mapOf( pair(Accounts.FACTORY_OFFLINE, "account.methods.offline"), pair(Accounts.FACTORY_AUTHLIB_INJECTOR, "account.methods.authlib_injector"), pair(Accounts.FACTORY_MICROSOFT, "account.methods.microsoft")); public static String getLocalizedLoginTypeName(AccountFactory factory) { return i18n(Optional.ofNullable(unlocalizedLoginTypeNames.get(factory)) .orElseThrow(() -> new IllegalArgumentException("Unrecognized account factory"))); } // ==== public static String localizeErrorMessage(Exception exception) { if (exception instanceof NoCharacterException) { return i18n("account.failed.no_character"); } else if (exception instanceof ServerDisconnectException) { if (exception.getCause() instanceof SSLException) { if (exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated")) { return i18n("account.failed.connect_authentication_server"); } if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) { return i18n("account.failed.dns"); } return i18n("account.failed.ssl"); } else { return i18n("account.failed.connect_authentication_server"); } } else if (exception instanceof ServerResponseMalformedException) { return i18n("account.failed.server_response_malformed"); } else if (exception instanceof RemoteAuthenticationException) { RemoteAuthenticationException remoteException = (RemoteAuthenticationException) exception; String remoteMessage = remoteException.getRemoteMessage(); if ("ForbiddenOperationException".equals(remoteException.getRemoteName()) && remoteMessage != null) { if (remoteMessage.contains("Invalid credentials")) { return i18n("account.failed.invalid_credentials"); } else if (remoteMessage.contains("Invalid token")) { return i18n("account.failed.invalid_token"); } else if (remoteMessage.contains("Invalid username or password")) { return i18n("account.failed.invalid_password"); } else { return remoteMessage; } } else if ("ResourceException".equals(remoteException.getRemoteName()) && remoteMessage != null) { if (remoteMessage.contains("The requested resource is no longer available")) { return i18n("account.failed.migration"); } else { return remoteMessage; } } return exception.getMessage(); } else if (exception instanceof AuthlibInjectorDownloadException) { return i18n("account.failed.injector_download_failure"); } else if (exception instanceof CharacterDeletedException) { return i18n("account.failed.character_deleted"); } else if (exception instanceof InvalidSkinException) { return i18n("account.skin.invalid_skin"); } else if (exception instanceof MicrosoftService.XboxAuthorizationException) { long errorCode = ((MicrosoftService.XboxAuthorizationException) exception).getErrorCode(); if (errorCode == MicrosoftService.XboxAuthorizationException.ADD_FAMILY) { return i18n("account.methods.microsoft.error.add_family"); } else if (errorCode == MicrosoftService.XboxAuthorizationException.COUNTRY_UNAVAILABLE) { return i18n("account.methods.microsoft.error.country_unavailable"); } else if (errorCode == MicrosoftService.XboxAuthorizationException.MISSING_XBOX_ACCOUNT) { return i18n("account.methods.microsoft.error.missing_xbox_account"); } else if (errorCode == MicrosoftService.XboxAuthorizationException.BANNED) { return i18n("account.methods.microsoft.error.banned"); } else { return i18n("account.methods.microsoft.error.unknown", errorCode); } } else if (exception instanceof MicrosoftService.XBox400Exception) { return i18n("account.methods.microsoft.error.wrong_verify_method"); } else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) { return i18n("account.methods.microsoft.error.no_character"); } else if (exception instanceof MicrosoftService.NoXuiException) { return i18n("account.methods.microsoft.error.add_family"); } else if (exception instanceof OAuthServer.MicrosoftAuthenticationNotSupportedException) { return i18n("account.methods.microsoft.snapshot"); } else if (exception instanceof OAuthAccount.WrongAccountException) { return i18n("account.failed.wrong_account"); } else if (exception.getClass() == AuthenticationException.class) { return exception.getLocalizedMessage(); } else { return exception.getClass().getName() + ": " + exception.getLocalizedMessage(); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/AuthlibInjectorServers.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import com.google.gson.JsonParseException; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.TolerableValidationException; import org.jackhuang.hmcl.util.gson.Validation; import org.jackhuang.hmcl.util.io.JarUtils; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @JsonSerializable public final class AuthlibInjectorServers implements Validation { public static final String CONFIG_FILENAME = "authlib-injectors.json"; private static final Set servers = new CopyOnWriteArraySet<>(); public static Set getServers() { return servers; } private final List urls; private AuthlibInjectorServers(List urls) { this.urls = urls; } @Override public void validate() throws JsonParseException, TolerableValidationException { if (this.urls == null) { throw new JsonParseException("authlib-injectors.json -> urls cannot be null."); } } public static void init() { Path configLocation; Path jarPath = JarUtils.thisJarPath(); if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) { configLocation = jarPath.getParent().resolve(CONFIG_FILENAME); } else { configLocation = Paths.get(CONFIG_FILENAME); } if (ConfigHolder.isNewlyCreated() && Files.exists(configLocation)) { AuthlibInjectorServers configInstance; try { configInstance = JsonUtils.fromJsonFile(configLocation, AuthlibInjectorServers.class); } catch (IOException | JsonParseException e) { LOG.warning("Malformed authlib-injectors.json", e); return; } if (!configInstance.urls.isEmpty()) { config().setPreferredLoginType(Accounts.getLoginType(Accounts.FACTORY_AUTHLIB_INJECTOR)); for (String url : configInstance.urls) { Task.supplyAsync(Schedulers.io(), () -> AuthlibInjectorServer.locateServer(url)) .thenAcceptAsync(Schedulers.javafx(), server -> { config().getAuthlibInjectorServers().add(server); servers.add(server); }) .start(); } } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/Config.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; import javafx.beans.Observable; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.ObservableMap; import javafx.collections.ObservableSet; import javafx.scene.paint.Paint; import org.hildan.fxgson.creators.ObservableListCreator; import org.hildan.fxgson.creators.ObservableMapCreator; import org.hildan.fxgson.creators.ObservableSetCreator; import org.hildan.fxgson.factories.JavaFxPropertyTypeAdapterFactory; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.theme.ThemeColor; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.gson.*; import org.jackhuang.hmcl.util.i18n.SupportedLocale; import org.jetbrains.annotations.Nullable; import java.net.Proxy; import java.nio.file.Path; import java.util.*; @JsonAdapter(value = Config.Adapter.class) public final class Config extends ObservableSetting { public static final int CURRENT_VERSION = 2; public static final int CURRENT_UI_VERSION = 0; public static final Gson CONFIG_GSON = new GsonBuilder() .registerTypeAdapter(Path.class, PathTypeAdapter.INSTANCE) .registerTypeAdapter(ObservableList.class, new ObservableListCreator()) .registerTypeAdapter(ObservableSet.class, new ObservableSetCreator()) .registerTypeAdapter(ObservableMap.class, new ObservableMapCreator()) .registerTypeAdapterFactory(new JavaFxPropertyTypeAdapterFactory(true, true)) .registerTypeAdapter(EnumBackgroundImage.class, new EnumOrdinalDeserializer<>(EnumBackgroundImage.class)) // backward compatibility for backgroundType .registerTypeAdapter(Proxy.Type.class, new EnumOrdinalDeserializer<>(Proxy.Type.class)) // backward compatibility for hasProxy .registerTypeAdapter(Paint.class, new PaintAdapter()) .setPrettyPrinting() .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) .create(); @Nullable public static Config fromJson(String json) throws JsonParseException { return CONFIG_GSON.fromJson(json, Config.class); } public Config() { tracker.markDirty(configVersion); tracker.markDirty(uiVersion); register(); } public String toJson() { return CONFIG_GSON.toJson(this); } // Properties @SerializedName("_version") private final IntegerProperty configVersion = new SimpleIntegerProperty(CURRENT_VERSION); public IntegerProperty configVersionProperty() { return configVersion; } public int getConfigVersion() { return configVersion.get(); } public void setConfigVersion(int configVersion) { this.configVersion.set(configVersion); } /** * The version of UI that the user have last used. * If there is a major change in UI, {@link Config#CURRENT_UI_VERSION} should be increased. * When {@link #CURRENT_UI_VERSION} is higher than the property, the user guide should be shown, * then this property is set to the same value as {@link #CURRENT_UI_VERSION}. * In particular, the property is default to 0, so that whoever open the application for the first time will see the guide. */ @SerializedName("uiVersion") private final IntegerProperty uiVersion = new SimpleIntegerProperty(CURRENT_UI_VERSION); public IntegerProperty uiVersionProperty() { return uiVersion; } public int getUiVersion() { return uiVersion.get(); } public void setUiVersion(int uiVersion) { this.uiVersion.set(uiVersion); } @SerializedName("x") private final DoubleProperty x = new SimpleDoubleProperty(); public DoubleProperty xProperty() { return x; } public double getX() { return x.get(); } public void setX(double x) { this.x.set(x); } @SerializedName("y") private final DoubleProperty y = new SimpleDoubleProperty(); public DoubleProperty yProperty() { return y; } public double getY() { return y.get(); } public void setY(double y) { this.y.set(y); } @SerializedName("width") private final DoubleProperty width = new SimpleDoubleProperty(); public DoubleProperty widthProperty() { return width; } public double getWidth() { return width.get(); } public void setWidth(double width) { this.width.set(width); } @SerializedName("height") private final DoubleProperty height = new SimpleDoubleProperty(); public DoubleProperty heightProperty() { return height; } public double getHeight() { return height.get(); } public void setHeight(double height) { this.height.set(height); } @SerializedName("localization") private final ObjectProperty localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT); public ObjectProperty localizationProperty() { return localization; } public SupportedLocale getLocalization() { return localization.get(); } public void setLocalization(SupportedLocale localization) { this.localization.set(localization); } @SerializedName("promptedVersion") private final StringProperty promptedVersion = new SimpleStringProperty(); public String getPromptedVersion() { return promptedVersion.get(); } public StringProperty promptedVersionProperty() { return promptedVersion; } public void setPromptedVersion(String promptedVersion) { this.promptedVersion.set(promptedVersion); } @SerializedName("acceptPreviewUpdate") private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false); public BooleanProperty acceptPreviewUpdateProperty() { return acceptPreviewUpdate; } public boolean isAcceptPreviewUpdate() { return acceptPreviewUpdate.get(); } public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) { this.acceptPreviewUpdate.set(acceptPreviewUpdate); } @SerializedName("disableAutoShowUpdateDialog") private final BooleanProperty disableAutoShowUpdateDialog = new SimpleBooleanProperty(false); public BooleanProperty disableAutoShowUpdateDialogProperty() { return disableAutoShowUpdateDialog; } public boolean isDisableAutoShowUpdateDialog() { return disableAutoShowUpdateDialog.get(); } public void setDisableAutoShowUpdateDialog(boolean disableAutoShowUpdateDialog) { this.disableAutoShowUpdateDialog.set(disableAutoShowUpdateDialog); } @SerializedName("disableAprilFools") private final BooleanProperty disableAprilFools = new SimpleBooleanProperty(false); public BooleanProperty disableAprilFoolsProperty() { return disableAprilFools; } public boolean isDisableAprilFools() { return disableAprilFools.get(); } public void setDisableAprilFools(boolean disableAprilFools) { this.disableAprilFools.set(disableAprilFools); } @SerializedName("shownTips") private final ObservableMap shownTips = FXCollections.observableHashMap(); public ObservableMap getShownTips() { return shownTips; } @SerializedName("commonDirType") private final ObjectProperty commonDirType = new RawPreservingObjectProperty<>(EnumCommonDirectory.DEFAULT); public ObjectProperty commonDirTypeProperty() { return commonDirType; } public EnumCommonDirectory getCommonDirType() { return commonDirType.get(); } public void setCommonDirType(EnumCommonDirectory commonDirType) { this.commonDirType.set(commonDirType); } @SerializedName("commonpath") private final StringProperty commonDirectory = new SimpleStringProperty(Metadata.MINECRAFT_DIRECTORY.toString()); public StringProperty commonDirectoryProperty() { return commonDirectory; } public String getCommonDirectory() { return commonDirectory.get(); } public void setCommonDirectory(String commonDirectory) { this.commonDirectory.set(commonDirectory); } @SerializedName("logLines") private final ObjectProperty logLines = new SimpleObjectProperty<>(); public ObjectProperty logLinesProperty() { return logLines; } public Integer getLogLines() { return logLines.get(); } public void setLogLines(Integer logLines) { this.logLines.set(logLines); } // UI @SerializedName("themeBrightness") private final StringProperty themeBrightness = new SimpleStringProperty("light"); public StringProperty themeBrightnessProperty() { return themeBrightness; } public String getThemeBrightness() { return themeBrightness.get(); } public void setThemeBrightness(String themeBrightness) { this.themeBrightness.set(themeBrightness); } @SerializedName("theme") private final ObjectProperty themeColor = new SimpleObjectProperty<>(ThemeColor.DEFAULT); public ObjectProperty themeColorProperty() { return themeColor; } public ThemeColor getThemeColor() { return themeColor.get(); } public void setThemeColor(ThemeColor themeColor) { this.themeColor.set(themeColor); } @SerializedName("fontFamily") private final StringProperty fontFamily = new SimpleStringProperty(); public StringProperty fontFamilyProperty() { return fontFamily; } public String getFontFamily() { return fontFamily.get(); } public void setFontFamily(String fontFamily) { this.fontFamily.set(fontFamily); } @SerializedName("fontSize") private final DoubleProperty fontSize = new SimpleDoubleProperty(12); public DoubleProperty fontSizeProperty() { return fontSize; } public double getFontSize() { return fontSize.get(); } public void setFontSize(double fontSize) { this.fontSize.set(fontSize); } @SerializedName("launcherFontFamily") private final StringProperty launcherFontFamily = new SimpleStringProperty(); public StringProperty launcherFontFamilyProperty() { return launcherFontFamily; } public String getLauncherFontFamily() { return launcherFontFamily.get(); } public void setLauncherFontFamily(String launcherFontFamily) { this.launcherFontFamily.set(launcherFontFamily); } @SerializedName("animationDisabled") private final BooleanProperty animationDisabled = new SimpleBooleanProperty( FXUtils.REDUCED_MOTION == Boolean.TRUE || !JavaRuntime.CURRENT_JIT_ENABLED || !FXUtils.GPU_ACCELERATION_ENABLED ); public BooleanProperty animationDisabledProperty() { return animationDisabled; } public boolean isAnimationDisabled() { return animationDisabled.get(); } public void setAnimationDisabled(boolean animationDisabled) { this.animationDisabled.set(animationDisabled); } @SerializedName("titleTransparent") private final BooleanProperty titleTransparent = new SimpleBooleanProperty(false); public BooleanProperty titleTransparentProperty() { return titleTransparent; } public boolean isTitleTransparent() { return titleTransparent.get(); } public void setTitleTransparent(boolean titleTransparent) { this.titleTransparent.set(titleTransparent); } @SerializedName("backgroundType") private final ObjectProperty backgroundImageType = new RawPreservingObjectProperty<>(EnumBackgroundImage.DEFAULT); public ObjectProperty backgroundImageTypeProperty() { return backgroundImageType; } public EnumBackgroundImage getBackgroundImageType() { return backgroundImageType.get(); } public void setBackgroundImageType(EnumBackgroundImage backgroundImageType) { this.backgroundImageType.set(backgroundImageType); } @SerializedName("bgpath") private final StringProperty backgroundImage = new SimpleStringProperty(); public StringProperty backgroundImageProperty() { return backgroundImage; } public String getBackgroundImage() { return backgroundImage.get(); } public void setBackgroundImage(String backgroundImage) { this.backgroundImage.set(backgroundImage); } @SerializedName("bgurl") private final StringProperty backgroundImageUrl = new SimpleStringProperty(); public StringProperty backgroundImageUrlProperty() { return backgroundImageUrl; } public String getBackgroundImageUrl() { return backgroundImageUrl.get(); } public void setBackgroundImageUrl(String backgroundImageUrl) { this.backgroundImageUrl.set(backgroundImageUrl); } @SerializedName("bgpaint") private final ObjectProperty backgroundPaint = new SimpleObjectProperty<>(); public Paint getBackgroundPaint() { return backgroundPaint.get(); } public ObjectProperty backgroundPaintProperty() { return backgroundPaint; } public void setBackgroundPaint(Paint backgroundPaint) { this.backgroundPaint.set(backgroundPaint); } @SerializedName("bgImageOpacity") private final IntegerProperty backgroundImageOpacity = new SimpleIntegerProperty(100); public IntegerProperty backgroundImageOpacityProperty() { return backgroundImageOpacity; } public int getBackgroundImageOpacity() { return backgroundImageOpacity.get(); } public void setBackgroundImageOpacity(int backgroundImageOpacity) { this.backgroundImageOpacity.set(backgroundImageOpacity); } // Networks @SerializedName("autoDownloadThreads") private final BooleanProperty autoDownloadThreads = new SimpleBooleanProperty(true); public BooleanProperty autoDownloadThreadsProperty() { return autoDownloadThreads; } public boolean getAutoDownloadThreads() { return autoDownloadThreads.get(); } public void setAutoDownloadThreads(boolean autoDownloadThreads) { this.autoDownloadThreads.set(autoDownloadThreads); } @SerializedName("downloadThreads") private final IntegerProperty downloadThreads = new SimpleIntegerProperty(64); public IntegerProperty downloadThreadsProperty() { return downloadThreads; } public int getDownloadThreads() { return downloadThreads.get(); } public void setDownloadThreads(int downloadThreads) { this.downloadThreads.set(downloadThreads); } @SerializedName("downloadType") private final StringProperty downloadType = new SimpleStringProperty(DownloadProviders.DEFAULT_DIRECT_PROVIDER_ID); public StringProperty downloadTypeProperty() { return downloadType; } public String getDownloadType() { return downloadType.get(); } public void setDownloadType(String downloadType) { this.downloadType.set(downloadType); } @SerializedName("autoChooseDownloadType") private final BooleanProperty autoChooseDownloadType = new SimpleBooleanProperty(true); public BooleanProperty autoChooseDownloadTypeProperty() { return autoChooseDownloadType; } public boolean isAutoChooseDownloadType() { return autoChooseDownloadType.get(); } public void setAutoChooseDownloadType(boolean autoChooseDownloadType) { this.autoChooseDownloadType.set(autoChooseDownloadType); } @SerializedName("versionListSource") private final StringProperty versionListSource = new SimpleStringProperty(DownloadProviders.DEFAULT_AUTO_PROVIDER_ID); public StringProperty versionListSourceProperty() { return versionListSource; } public String getVersionListSource() { return versionListSource.get(); } public void setVersionListSource(String versionListSource) { this.versionListSource.set(versionListSource); } @SerializedName("hasProxy") private final BooleanProperty hasProxy = new SimpleBooleanProperty(); public BooleanProperty hasProxyProperty() { return hasProxy; } public boolean hasProxy() { return hasProxy.get(); } public void setHasProxy(boolean hasProxy) { this.hasProxy.set(hasProxy); } @SerializedName("hasProxyAuth") private final BooleanProperty hasProxyAuth = new SimpleBooleanProperty(); public BooleanProperty hasProxyAuthProperty() { return hasProxyAuth; } public boolean hasProxyAuth() { return hasProxyAuth.get(); } public void setHasProxyAuth(boolean hasProxyAuth) { this.hasProxyAuth.set(hasProxyAuth); } @SerializedName("proxyType") private final ObjectProperty proxyType = new SimpleObjectProperty<>(Proxy.Type.HTTP); public ObjectProperty proxyTypeProperty() { return proxyType; } public Proxy.Type getProxyType() { return proxyType.get(); } public void setProxyType(Proxy.Type proxyType) { this.proxyType.set(proxyType); } @SerializedName("proxyHost") private final StringProperty proxyHost = new SimpleStringProperty(); public StringProperty proxyHostProperty() { return proxyHost; } public String getProxyHost() { return proxyHost.get(); } public void setProxyHost(String proxyHost) { this.proxyHost.set(proxyHost); } @SerializedName("proxyPort") private final IntegerProperty proxyPort = new SimpleIntegerProperty(); public IntegerProperty proxyPortProperty() { return proxyPort; } public int getProxyPort() { return proxyPort.get(); } public void setProxyPort(int proxyPort) { this.proxyPort.set(proxyPort); } @SerializedName("proxyUserName") private final StringProperty proxyUser = new SimpleStringProperty(); public StringProperty proxyUserProperty() { return proxyUser; } public String getProxyUser() { return proxyUser.get(); } public void setProxyUser(String proxyUser) { this.proxyUser.set(proxyUser); } @SerializedName("proxyPassword") private final StringProperty proxyPass = new SimpleStringProperty(); public StringProperty proxyPassProperty() { return proxyPass; } public String getProxyPass() { return proxyPass.get(); } public void setProxyPass(String proxyPass) { this.proxyPass.set(proxyPass); } // Game @SerializedName("disableAutoGameOptions") private final BooleanProperty disableAutoGameOptions = new SimpleBooleanProperty(false); public BooleanProperty disableAutoGameOptionsProperty() { return disableAutoGameOptions; } public boolean isDisableAutoGameOptions() { return disableAutoGameOptions.get(); } public void setDisableAutoGameOptions(boolean disableAutoGameOptions) { this.disableAutoGameOptions.set(disableAutoGameOptions); } // Accounts @SerializedName("authlibInjectorServers") private final ObservableList authlibInjectorServers = FXCollections.observableArrayList(server -> new Observable[]{server}); public ObservableList getAuthlibInjectorServers() { return authlibInjectorServers; } @SerializedName("addedLittleSkin") private final BooleanProperty addedLittleSkin = new SimpleBooleanProperty(false); public BooleanProperty addedLittleSkinProperty() { return addedLittleSkin; } public boolean isAddedLittleSkin() { return addedLittleSkin.get(); } public void setAddedLittleSkin(boolean addedLittleSkin) { this.addedLittleSkin.set(addedLittleSkin); } /** * The preferred login type to use when the user wants to add an account. */ @SerializedName("preferredLoginType") private final StringProperty preferredLoginType = new SimpleStringProperty(); public StringProperty preferredLoginTypeProperty() { return preferredLoginType; } public String getPreferredLoginType() { return preferredLoginType.get(); } public void setPreferredLoginType(String preferredLoginType) { this.preferredLoginType.set(preferredLoginType); } @SerializedName("selectedAccount") private final StringProperty selectedAccount = new SimpleStringProperty(); public StringProperty selectedAccountProperty() { return selectedAccount; } public String getSelectedAccount() { return selectedAccount.get(); } public void setSelectedAccount(String selectedAccount) { this.selectedAccount.set(selectedAccount); } @SerializedName("accounts") private final ObservableList> accountStorages = FXCollections.observableArrayList(); public ObservableList> getAccountStorages() { return accountStorages; } // Configurations @SerializedName("last") private final StringProperty selectedProfile = new SimpleStringProperty(""); public StringProperty selectedProfileProperty() { return selectedProfile; } public String getSelectedProfile() { return selectedProfile.get(); } public void setSelectedProfile(String selectedProfile) { this.selectedProfile.set(selectedProfile); } @SerializedName("configurations") private final SimpleMapProperty configurations = new SimpleMapProperty<>(FXCollections.observableMap(new TreeMap<>())); public MapProperty getConfigurations() { return configurations; } public static final class Adapter extends ObservableSetting.Adapter { @Override protected Config createInstance() { return new Config(); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigHolder.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import com.google.gson.JsonParseException; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; import java.io.IOException; import java.nio.file.*; import java.util.Locale; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class ConfigHolder { private ConfigHolder() { } public static final String CONFIG_FILENAME = "hmcl.json"; public static final String CONFIG_FILENAME_LINUX = ".hmcl.json"; public static final Path GLOBAL_CONFIG_PATH = Metadata.HMCL_GLOBAL_DIRECTORY.resolve("config.json"); private static Path configLocation; private static Config configInstance; private static GlobalConfig globalConfigInstance; private static boolean newlyCreated; private static boolean ownerChanged = false; private static boolean unsupportedVersion = false; public static Config config() { if (configInstance == null) { throw new IllegalStateException("Configuration hasn't been loaded"); } return configInstance; } public static GlobalConfig globalConfig() { if (globalConfigInstance == null) { throw new IllegalStateException("Configuration hasn't been loaded"); } return globalConfigInstance; } public static Path configLocation() { return configLocation; } public static boolean isNewlyCreated() { return newlyCreated; } public static boolean isOwnerChanged() { return ownerChanged; } public static boolean isUnsupportedVersion() { return unsupportedVersion; } public static void init() throws IOException { if (configInstance != null) { throw new IllegalStateException("Configuration is already loaded"); } configLocation = locateConfig(); LOG.info("Config location: " + configLocation); configInstance = loadConfig(); if (!unsupportedVersion) configInstance.addListener(source -> FileSaver.save(configLocation, configInstance.toJson())); globalConfigInstance = loadGlobalConfig(); globalConfigInstance.addListener(source -> FileSaver.save(GLOBAL_CONFIG_PATH, globalConfigInstance.toJson())); Locale.setDefault(config().getLocalization().getLocale()); I18n.setLocale(configInstance.getLocalization()); LOG.setLogRetention(globalConfig().getLogRetention()); Settings.init(); if (newlyCreated) { LOG.info("Creating config file " + configLocation); FileUtils.saveSafely(configLocation, configInstance.toJson()); } if (!Files.isWritable(configLocation)) { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && configLocation.getFileSystem() == FileSystems.getDefault() && configLocation.toFile().canWrite()) { LOG.warning("Config at " + configLocation + " is not writable, but it seems to be a Samba share or OpenJDK bug"); // There are some serious problems with the implementation of Samba or OpenJDK throw new SambaException(); } else { // the config cannot be saved // throw up the error now to prevent further data loss throw new IOException("Config at " + configLocation + " is not writable"); } } } private static Path locateConfig() { Path defaultConfigFile = Metadata.HMCL_CURRENT_DIRECTORY.resolve(CONFIG_FILENAME); if (Files.isRegularFile(defaultConfigFile)) return defaultConfigFile; try { Path jarPath = JarUtils.thisJarPath(); if (jarPath != null && Files.isRegularFile(jarPath) && Files.isWritable(jarPath)) { jarPath = jarPath.getParent(); Path config = jarPath.resolve(CONFIG_FILENAME); if (Files.isRegularFile(config)) return config; Path dotConfig = jarPath.resolve(CONFIG_FILENAME_LINUX); if (Files.isRegularFile(dotConfig)) return dotConfig; } } catch (Throwable ignore) { } Path config = Paths.get(CONFIG_FILENAME); if (Files.isRegularFile(config)) return config; Path dotConfig = Paths.get(CONFIG_FILENAME_LINUX); if (Files.isRegularFile(dotConfig)) return dotConfig; // create new return defaultConfigFile; } private static Config loadConfig() throws IOException { if (Files.exists(configLocation)) { try { if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS && "root".equals(System.getProperty("user.name")) && !"root".equals(Files.getOwner(configLocation).getName())) { ownerChanged = true; } } catch (IOException e1) { LOG.warning("Failed to get owner"); } try { String content = Files.readString(configLocation); Config deserialized = Config.fromJson(content); if (deserialized == null) { LOG.info("Config is empty"); } else { int configVersion = deserialized.getConfigVersion(); if (configVersion < Config.CURRENT_VERSION) { ConfigUpgrader.upgradeConfig(deserialized, content); } else if (configVersion > Config.CURRENT_VERSION) { unsupportedVersion = true; LOG.warning(String.format("Current HMCL only support the configuration version up to %d. However, the version now is %d.", Config.CURRENT_VERSION, configVersion)); } return deserialized; } } catch (JsonParseException e) { LOG.warning("Malformed config.", e); } } newlyCreated = true; return new Config(); } // Global Config private static GlobalConfig loadGlobalConfig() throws IOException { if (Files.exists(GLOBAL_CONFIG_PATH)) { try { String content = Files.readString(GLOBAL_CONFIG_PATH); GlobalConfig deserialized = GlobalConfig.fromJson(content); if (deserialized == null) { LOG.info("Config is empty"); } else { return deserialized; } } catch (JsonParseException e) { LOG.warning("Malformed config.", e); } } LOG.info("Creating an empty global config"); return new GlobalConfig(); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/ConfigUpgrader.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import com.google.gson.Gson; import org.jackhuang.hmcl.util.StringUtils; import java.util.Collections; import java.util.HashMap; import java.util.Map; import static org.jackhuang.hmcl.util.Lang.tryCast; import static org.jackhuang.hmcl.util.logging.Logger.LOG; final class ConfigUpgrader { private ConfigUpgrader() { } /** * This method is for the compatibility with old HMCL versions. * * @param deserialized deserialized config settings * @param rawContent raw json content of the config settings without modification */ static void upgradeConfig(Config deserialized, String rawContent) { int configVersion = deserialized.getConfigVersion(); if (configVersion >= Config.CURRENT_VERSION) return; LOG.info(String.format("Updating configuration from %d to %d.", configVersion, Config.CURRENT_VERSION)); Map rawJson = Collections.unmodifiableMap(new Gson().>fromJson(rawContent, Map.class)); if (configVersion < 1) { // Upgrade configuration of HMCL 2.x: Convert OfflineAccounts whose stored uuid is important. tryCast(rawJson.get("auth"), Map.class).ifPresent(auth -> { tryCast(auth.get("offline"), Map.class).ifPresent(offline -> { String selected = rawJson.containsKey("selectedAccount") ? null : tryCast(offline.get("IAuthenticator_UserName"), String.class).orElse(null); tryCast(offline.get("uuidMap"), Map.class).ifPresent(uuidMap -> { ((Map) uuidMap).forEach((key, value) -> { Map storage = new HashMap<>(); storage.put("type", "offline"); storage.put("username", key); storage.put("uuid", value); if (key.equals(selected)) { storage.put("selected", true); } deserialized.getAccountStorages().add(storage); }); }); }); }); // Upgrade configuration of HMCL earlier than 3.1.70 if (!rawJson.containsKey("commonDirType")) deserialized.setCommonDirType(deserialized.getCommonDirectory().equals(Settings.getDefaultCommonDirectory()) ? EnumCommonDirectory.DEFAULT : EnumCommonDirectory.CUSTOM); if (!rawJson.containsKey("backgroundType")) deserialized.setBackgroundImageType(StringUtils.isNotBlank(deserialized.getBackgroundImage()) ? EnumBackgroundImage.CUSTOM : EnumBackgroundImage.DEFAULT); if (!rawJson.containsKey("hasProxy")) deserialized.setHasProxy(StringUtils.isNotBlank(deserialized.getProxyHost())); if (!rawJson.containsKey("hasProxyAuth")) deserialized.setHasProxyAuth(StringUtils.isNotBlank(deserialized.getProxyUser())); if (!rawJson.containsKey("downloadType")) { tryCast(rawJson.get("downloadtype"), Number.class) .map(Number::intValue) .ifPresent(id -> { if (id == 0) { deserialized.setDownloadType("mojang"); } else if (id == 1) { deserialized.setDownloadType("bmclapi"); } }); } } deserialized.setConfigVersion(Config.CURRENT_VERSION); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/DownloadProviders.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import javafx.beans.InvalidationListener; import org.jackhuang.hmcl.download.*; import org.jackhuang.hmcl.task.DownloadException; import org.jackhuang.hmcl.task.FetchTask; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.ResponseCodeException; import javax.net.ssl.SSLHandshakeException; import java.io.FileNotFoundException; import java.net.SocketTimeoutException; import java.net.URI; import java.nio.file.AccessDeniedException; import java.util.List; import java.util.Map; import java.util.concurrent.CancellationException; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class DownloadProviders { private DownloadProviders() { } public static final String DEFAULT_AUTO_PROVIDER_ID = "balanced"; public static final String DEFAULT_DIRECT_PROVIDER_ID = "mojang"; private static final DownloadProviderWrapper PROVIDER_WRAPPER; private static final DownloadProvider DEFAULT_PROVIDER; public static final Map DIRECT_PROVIDERS; public static final Map AUTO_PROVIDERS; static { String bmclapiRoot = System.getProperty("hmcl.bmclapi.override", "https://bmclapi2.bangbang93.com"); BMCLAPIDownloadProvider bmclapiRaw = new BMCLAPIDownloadProvider(bmclapiRoot); DownloadProvider mojang = new MojangDownloadProvider(); DownloadProvider bmclapi = new AutoDownloadProvider(bmclapiRaw, mojang); DEFAULT_PROVIDER = mojang; DIRECT_PROVIDERS = Lang.mapOf( pair("mojang", mojang), pair("bmclapi", bmclapi) ); AUTO_PROVIDERS = Lang.mapOf( pair("balanced", LocaleUtils.IS_CHINA_MAINLAND ? bmclapi : mojang), pair("official", LocaleUtils.IS_CHINA_MAINLAND ? new AutoDownloadProvider( List.of(mojang, bmclapiRaw), List.of(bmclapiRaw, mojang) ) : mojang), pair("mirror", bmclapi) ); PROVIDER_WRAPPER = new DownloadProviderWrapper(DEFAULT_PROVIDER); } static void init() { InvalidationListener onChangeDownloadThreads = observable -> { FetchTask.setDownloadExecutorConcurrency(config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads()); }; config().autoDownloadThreadsProperty().addListener(onChangeDownloadThreads); config().downloadThreadsProperty().addListener(onChangeDownloadThreads); onChangeDownloadThreads.invalidated(null); InvalidationListener onChangeDownloadSource = observable -> { if (config().isAutoChooseDownloadType()) { String versionListSource = config().getVersionListSource(); DownloadProvider downloadProvider = versionListSource != null ? AUTO_PROVIDERS.getOrDefault(versionListSource, DEFAULT_PROVIDER) : DEFAULT_PROVIDER; PROVIDER_WRAPPER.setProvider(downloadProvider); } else { String downloadType = config().getDownloadType(); PROVIDER_WRAPPER.setProvider(downloadType != null ? DIRECT_PROVIDERS.getOrDefault(downloadType, DEFAULT_PROVIDER) : DEFAULT_PROVIDER); } }; config().versionListSourceProperty().addListener(onChangeDownloadSource); config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource); config().downloadTypeProperty().addListener(onChangeDownloadSource); onChangeDownloadSource.invalidated(null); } /** * Get current primary preferred download provider */ public static DownloadProvider getDownloadProvider() { return PROVIDER_WRAPPER; } public static String localizeErrorMessage(Throwable exception) { if (exception instanceof DownloadException) { URI uri = ((DownloadException) exception).getUri(); if (exception.getCause() instanceof SocketTimeoutException) { return i18n("install.failed.downloading.timeout", uri); } else if (exception.getCause() instanceof ResponseCodeException) { ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause(); if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) { return i18n("download.code." + responseCodeException.getResponseCode(), uri); } else { return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()); } } else if (exception.getCause() instanceof FileNotFoundException) { return i18n("download.code.404", uri); } else if (exception.getCause() instanceof AccessDeniedException) { return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.access_denied", ((AccessDeniedException) exception.getCause()).getFile()); } else if (exception.getCause() instanceof ArtifactMalformedException) { return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.artifact_malformed"); } else if (exception.getCause() instanceof SSLHandshakeException && !(exception.getCause().getMessage() != null && exception.getCause().getMessage().contains("Remote host terminated"))) { if (exception.getCause().getMessage() != null && (exception.getCause().getMessage().contains("No name matching") || exception.getCause().getMessage().contains("No subject alternative DNS name matching"))) { return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.dns.pollution"); } return i18n("install.failed.downloading.detail", uri) + "\n" + i18n("exception.ssl_handshake"); } else { return i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()); } } else if (exception instanceof ArtifactMalformedException) { return i18n("exception.artifact_malformed"); } else if (exception instanceof CancellationException) { return i18n("message.cancelled"); } return StringUtils.getStackTrace(exception); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumBackgroundImage.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; public enum EnumBackgroundImage { DEFAULT, CUSTOM, CLASSIC, NETWORK, TRANSLUCENT, // Deprecated PAINT } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/EnumCommonDirectory.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; public enum EnumCommonDirectory { /** * %appdata%/.minecraft or ~/.minecraft */ DEFAULT, /** * user customized common directory. */ CUSTOM } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/FontManager.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.text.Font; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.util.Lazy; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.SystemUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.net.MalformedURLException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class FontManager { public static final String[] FONT_EXTENSIONS = { "ttf", "otf", "woff" }; public static final double DEFAULT_FONT_SIZE = 12.0f; private static final Lazy DEFAULT_FONT = new Lazy<>(() -> { Font font; // Recommended font = tryLoadLocalizedFont(Metadata.HMCL_CURRENT_DIRECTORY.resolve("font")); if (font != null) return font; font = tryLoadLocalizedFont(Metadata.HMCL_GLOBAL_DIRECTORY.resolve("font")); if (font != null) return font; // Legacy font = tryLoadDefaultFont(Metadata.HMCL_CURRENT_DIRECTORY); if (font != null) return font; font = tryLoadDefaultFont(Metadata.CURRENT_DIRECTORY); if (font != null) return font; font = tryLoadDefaultFont(Metadata.HMCL_GLOBAL_DIRECTORY); if (font != null) return font; Path thisJar = JarUtils.thisJarPath(); if (thisJar != null && thisJar.getParent() != null) { font = tryLoadDefaultFont(thisJar.getParent()); if (font != null) return font; } // Default String fcMatchPattern; if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && !(fcMatchPattern = I18n.getLocale().getFcMatchPattern()).isEmpty()) return findByFcMatch(fcMatchPattern); else return null; }); private static final ObjectProperty font = new SimpleObjectProperty<>(); static { updateFont(); LOG.info("Font: " + (font.get() != null ? font.get().family() : "System")); } private static void updateFont() { String fontFamily = config().getLauncherFontFamily(); if (fontFamily == null) fontFamily = System.getProperty("hmcl.font.override"); if (fontFamily == null) fontFamily = System.getenv("HMCL_FONT"); if (fontFamily == null) { Font defaultFont = DEFAULT_FONT.get(); font.set(defaultFont != null ? new FontReference(defaultFont) : null); } else { font.set(new FontReference(fontFamily)); } } private static Font tryLoadDefaultFont(Path dir) { for (String extension : FONT_EXTENSIONS) { Path path = dir.resolve("font." + extension); if (Files.isRegularFile(path)) { LOG.info("Load font file: " + path); try { Font font = Font.loadFont(path.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE); if (font != null) { return font; } } catch (MalformedURLException ignored) { } LOG.warning("Failed to load font " + path); } } return null; } private static Font tryLoadLocalizedFont(Path dir) { Map> fontFiles = LocaleUtils.findAllLocalizedFiles(dir, "font", Set.of(FONT_EXTENSIONS)); if (fontFiles.isEmpty()) return null; List candidateLocales = I18n.getLocale().getCandidateLocales(); for (Locale locale : candidateLocales) { Map extToFiles = fontFiles.get(LocaleUtils.toLanguageKey(locale)); if (extToFiles != null) { for (String ext : FONT_EXTENSIONS) { Path fontFile = extToFiles.get(ext); if (fontFile != null) { LOG.info("Load font file: " + fontFile); try { Font font = Font.loadFont( fontFile.toAbsolutePath().normalize().toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE); if (font != null) return font; } catch (MalformedURLException ignored) { } LOG.warning("Failed to load font " + fontFile); } } } } return null; } public static Font findByFcMatch(String pattern) { Path fcMatch = SystemUtils.which("fc-match"); if (fcMatch == null) return null; try { String result = SystemUtils.run(fcMatch.toString(), pattern, "--format", "%{family}\\n%{file}").trim(); String[] results = result.split("\\n"); if (results.length != 2 || results[0].isEmpty() || results[1].isEmpty()) { LOG.warning("Unexpected output from fc-match: " + result); return null; } String family = results[0].trim(); String path = results[1]; Path file = Paths.get(path).toAbsolutePath().normalize(); if (!Files.isRegularFile(file)) { LOG.warning("Font file does not exist: " + path); return null; } LOG.info("Load font file: " + path); Font[] fonts = Font.loadFonts(file.toUri().toURL().toExternalForm(), DEFAULT_FONT_SIZE); if (fonts == null) { LOG.warning("Failed to load font from " + path); return null; } else if (fonts.length == 0) { LOG.warning("No fonts loaded from " + path); return null; } for (Font font : fonts) { if (font.getFamily().equalsIgnoreCase(family)) { return font; } } if (family.indexOf(',') >= 0) { for (String candidateFamily : family.split(",")) { for (Font font : fonts) { if (font.getFamily().equalsIgnoreCase(candidateFamily)) { return font; } } } } LOG.warning(String.format("Family '%s' not found in font file '%s'", family, path)); return fonts[0]; } catch (Throwable e) { LOG.warning("Failed to get default font with fc-match", e); return null; } } public static ReadOnlyObjectProperty fontProperty() { return font; } public static FontReference getFont() { return font.get(); } public static void setFontFamily(String fontFamily) { config().setLauncherFontFamily(fontFamily); updateFont(); } // https://github.com/HMCL-dev/HMCL/issues/4072 public record FontReference(@NotNull String family, @Nullable String style) { public FontReference { Objects.requireNonNull(family); } public FontReference(@NotNull String family) { this(family, null); } public FontReference(@NotNull Font font) { this(font.getFamily(), font.getStyle()); } } private FontManager() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableSet; import org.jackhuang.hmcl.util.gson.ObservableSetting; import org.jetbrains.annotations.Nullable; import java.util.*; @JsonAdapter(GlobalConfig.Adapter.class) public final class GlobalConfig extends ObservableSetting { @Nullable public static GlobalConfig fromJson(String json) throws JsonParseException { return Config.CONFIG_GSON.fromJson(json, GlobalConfig.class); } public GlobalConfig() { register(); } public String toJson() { return Config.CONFIG_GSON.toJson(this); } @SerializedName("agreementVersion") private final IntegerProperty agreementVersion = new SimpleIntegerProperty(); public IntegerProperty agreementVersionProperty() { return agreementVersion; } public int getAgreementVersion() { return agreementVersion.get(); } public void setAgreementVersion(int agreementVersion) { this.agreementVersion.set(agreementVersion); } @SerializedName("terracottaAgreementVersion") private final IntegerProperty terracottaAgreementVersion = new SimpleIntegerProperty(); public IntegerProperty terracottaAgreementVersionProperty() { return terracottaAgreementVersion; } public int getTerracottaAgreementVersion() { return terracottaAgreementVersion.get(); } public void setTerracottaAgreementVersion(int terracottaAgreementVersion) { this.terracottaAgreementVersion.set(terracottaAgreementVersion); } @SerializedName("platformPromptVersion") private final IntegerProperty platformPromptVersion = new SimpleIntegerProperty(); public IntegerProperty platformPromptVersionProperty() { return platformPromptVersion; } public int getPlatformPromptVersion() { return platformPromptVersion.get(); } public void setPlatformPromptVersion(int platformPromptVersion) { this.platformPromptVersion.set(platformPromptVersion); } @SerializedName("logRetention") private final IntegerProperty logRetention = new SimpleIntegerProperty(20); public IntegerProperty logRetentionProperty() { return logRetention; } public int getLogRetention() { return logRetention.get(); } public void setLogRetention(int logRetention) { this.logRetention.set(logRetention); } @SerializedName("enableOfflineAccount") private final BooleanProperty enableOfflineAccount = new SimpleBooleanProperty(false); public BooleanProperty enableOfflineAccountProperty() { return enableOfflineAccount; } public boolean isEnableOfflineAccount() { return enableOfflineAccount.get(); } public void setEnableOfflineAccount(boolean value) { enableOfflineAccount.set(value); } @SerializedName("fontAntiAliasing") private final StringProperty fontAntiAliasing = new SimpleStringProperty(); public StringProperty fontAntiAliasingProperty() { return fontAntiAliasing; } public String getFontAntiAliasing() { return fontAntiAliasing.get(); } public void setFontAntiAliasing(String value) { this.fontAntiAliasing.set(value); } @SerializedName("userJava") private final ObservableSet userJava = FXCollections.observableSet(new LinkedHashSet<>()); public ObservableSet getUserJava() { return userJava; } @SerializedName("disabledJava") private final ObservableSet disabledJava = FXCollections.observableSet(new LinkedHashSet<>()); public ObservableSet getDisabledJava() { return disabledJava; } static final class Adapter extends ObservableSetting.Adapter { @Override protected GlobalConfig createInstance() { return new GlobalConfig(); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/JavaVersionType.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2024 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; /** * @author Glavo */ public enum JavaVersionType { DEFAULT, AUTO, VERSION, DETECTED, CUSTOM } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherVisibility.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; /** * The visibility of launcher. * @author huangyuhui */ public enum LauncherVisibility { /** * Close the launcher anyway when the game process created even if failed to * launch game. */ CLOSE, /** * Hide the launcher when the game process created, if failed to launch * game, will show the log window. */ HIDE, /** * Keep the launcher visible even if the game launched successfully. */ KEEP, /** * Hide the launcher and reopen it when game closes. */ HIDE_AND_REOPEN; public boolean isDaemon() { return this != CLOSE; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/Profile.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.property.*; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.EventPriority; import org.jackhuang.hmcl.event.RefreshedVersionsEvent; import org.jackhuang.hmcl.game.HMCLCacheRepository; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.Version; import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.javafx.ObservableHelper; import java.lang.reflect.Type; import java.nio.file.Path; import java.util.Optional; import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; /** * * @author huangyuhui */ @JsonAdapter(Profile.Serializer.class) public final class Profile implements Observable { private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private final HMCLGameRepository repository; private final StringProperty selectedVersion = new SimpleStringProperty(); public StringProperty selectedVersionProperty() { return selectedVersion; } public String getSelectedVersion() { return selectedVersion.get(); } public void setSelectedVersion(String selectedVersion) { this.selectedVersion.set(selectedVersion); } private final ObjectProperty gameDir; public ObjectProperty gameDirProperty() { return gameDir; } public Path getGameDir() { return gameDir.get(); } public void setGameDir(Path gameDir) { this.gameDir.set(gameDir); } private final ReadOnlyObjectWrapper global = new ReadOnlyObjectWrapper<>(this, "global"); public ReadOnlyObjectProperty globalProperty() { return global.getReadOnlyProperty(); } public VersionSetting getGlobal() { return global.get(); } private final SimpleStringProperty name; public StringProperty nameProperty() { return name; } public String getName() { return name.get(); } public void setName(String name) { this.name.set(name); } private final BooleanProperty useRelativePath = new SimpleBooleanProperty(this, "useRelativePath", false); public BooleanProperty useRelativePathProperty() { return useRelativePath; } public boolean isUseRelativePath() { return useRelativePath.get(); } public void setUseRelativePath(boolean useRelativePath) { this.useRelativePath.set(useRelativePath); } public Profile(String name) { this(name, Path.of(".minecraft")); } public Profile(String name, Path initialGameDir) { this(name, initialGameDir, new VersionSetting()); } public Profile(String name, Path initialGameDir, VersionSetting global) { this(name, initialGameDir, global, null, false); } public Profile(String name, Path initialGameDir, VersionSetting global, String selectedVersion, boolean useRelativePath) { this.name = new SimpleStringProperty(this, "name", name); gameDir = new SimpleObjectProperty<>(this, "gameDir", initialGameDir); repository = new HMCLGameRepository(this, initialGameDir); this.global.set(global == null ? new VersionSetting() : global); this.selectedVersion.set(selectedVersion); this.useRelativePath.set(useRelativePath); gameDir.addListener((a, b, newValue) -> repository.changeDirectory(newValue)); this.selectedVersion.addListener(o -> checkSelectedVersion()); listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> checkSelectedVersion(), EventPriority.HIGHEST)); addPropertyChangedListener(onInvalidating(this::invalidate)); } private void checkSelectedVersion() { runInFX(() -> { if (!repository.isLoaded()) return; String newValue = selectedVersion.get(); if (!repository.hasVersion(newValue)) { Optional version = repository.getVersions().stream().findFirst().map(Version::getId); if (version.isPresent()) selectedVersion.setValue(version.get()); else if (newValue != null) selectedVersion.setValue(null); } }); } public HMCLGameRepository getRepository() { return repository; } public DefaultDependencyManager getDependency() { return getDependency(DownloadProviders.getDownloadProvider()); } public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) { return new DefaultDependencyManager(repository, downloadProvider, HMCLCacheRepository.REPOSITORY); } public VersionSetting getVersionSetting(String id) { return repository.getVersionSetting(id); } @Override public String toString() { return new ToStringBuilder(this) .append("gameDir", getGameDir()) .append("name", getName()) .append("useRelativePath", isUseRelativePath()) .toString(); } private void addPropertyChangedListener(InvalidationListener listener) { name.addListener(listener); global.addListener(listener); gameDir.addListener(listener); useRelativePath.addListener(listener); global.get().addListener(listener); selectedVersion.addListener(listener); } private ObservableHelper observableHelper = new ObservableHelper(this); @Override public void addListener(InvalidationListener listener) { observableHelper.addListener(listener); } @Override public void removeListener(InvalidationListener listener) { observableHelper.removeListener(listener); } private void invalidate() { Platform.runLater(observableHelper::invalidate); } public static class ProfileVersion { private final Profile profile; private final String version; public ProfileVersion(Profile profile, String version) { this.profile = profile; this.version = version; } public Profile getProfile() { return profile; } public String getVersion() { return version; } } public static final class Serializer implements JsonSerializer, JsonDeserializer { @Override public JsonElement serialize(Profile src, Type typeOfSrc, JsonSerializationContext context) { if (src == null) return JsonNull.INSTANCE; JsonObject jsonObject = new JsonObject(); jsonObject.add("global", context.serialize(src.getGlobal())); jsonObject.addProperty("gameDir", src.getGameDir().toString()); jsonObject.addProperty("useRelativePath", src.isUseRelativePath()); jsonObject.addProperty("selectedMinecraftVersion", src.getSelectedVersion()); return jsonObject; } @Override public Profile deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (!(json instanceof JsonObject obj)) return null; String gameDir = Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse(""); return new Profile("Default", Path.of(gameDir), context.deserialize(obj.get("global"), VersionSetting.class), Optional.ofNullable(obj.get("selectedMinecraftVersion")).map(JsonElement::getAsString).orElse(""), Optional.ofNullable(obj.get("useRelativePath")).map(JsonElement::getAsBoolean).orElse(false)); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/Profiles.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import javafx.application.Platform; import javafx.beans.Observable; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.RefreshedVersionsEvent; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.TreeMap; import java.util.function.Consumer; 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.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class Profiles { public static final String DEFAULT_PROFILE = "Default"; public static final String HOME_PROFILE = "Home"; private Profiles() { } public static String getProfileDisplayName(Profile profile) { return switch (profile.getName()) { case Profiles.DEFAULT_PROFILE -> i18n("profile.default"); case Profiles.HOME_PROFILE -> i18n("profile.home"); default -> profile.getName(); }; } private static final ObservableList profiles = observableArrayList(profile -> new Observable[] { profile }); private static final ReadOnlyListWrapper profilesWrapper = new ReadOnlyListWrapper<>(profiles); private static final ObjectProperty selectedProfile = new SimpleObjectProperty() { { profiles.addListener(onInvalidating(this::invalidated)); } @Override protected void invalidated() { if (!initialized) return; Profile profile = get(); if (profiles.isEmpty()) { if (profile != null) { set(null); return; } } else { if (!profiles.contains(profile)) { set(profiles.get(0)); return; } } config().setSelectedProfile(profile == null ? "" : profile.getName()); if (profile != null) { if (profile.getRepository().isLoaded()) selectedVersion.bind(profile.selectedVersionProperty()); else { selectedVersion.unbind(); selectedVersion.set(null); // bind when repository was reloaded. profile.getRepository().refreshVersionsAsync().start(); } } else { selectedVersion.unbind(); selectedVersion.set(null); } } }; private static void checkProfiles() { if (profiles.isEmpty()) { Profile current = new Profile(Profiles.DEFAULT_PROFILE, Path.of(".minecraft"), new VersionSetting(), null, true); Profile home = new Profile(Profiles.HOME_PROFILE, Metadata.MINECRAFT_DIRECTORY); Platform.runLater(() -> profiles.addAll(current, home)); } } /** * True if {@link #init()} hasn't been called. */ private static boolean initialized = false; static { profiles.addListener(onInvalidating(Profiles::updateProfileStorages)); profiles.addListener(onInvalidating(Profiles::checkProfiles)); selectedProfile.addListener((a, b, newValue) -> { if (newValue != null) newValue.getRepository().refreshVersionsAsync().start(); }); } private static void updateProfileStorages() { // don't update the underlying storage before data loading is completed // otherwise it might cause data loss if (!initialized) return; // update storage TreeMap newConfigurations = new TreeMap<>(); for (Profile profile : profiles) { newConfigurations.put(profile.getName(), profile); } config().getConfigurations().setValue(FXCollections.observableMap(newConfigurations)); } /** * Called when it's ready to load profiles from {@link ConfigHolder#config()}. */ static void init() { if (initialized) throw new IllegalStateException("Already initialized"); HashSet names = new HashSet<>(); config().getConfigurations().forEach((name, profile) -> { if (!names.add(name)) return; profiles.add(profile); profile.setName(name); }); checkProfiles(); // Platform.runLater is necessary or profiles will be empty // since checkProfiles adds 2 base profile later. Platform.runLater(() -> { initialized = true; selectedProfile.set( profiles.stream() .filter(it -> it.getName().equals(config().getSelectedProfile())) .findFirst() .orElse(profiles.get(0))); }); EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class).registerWeak(event -> { runInFX(() -> { Profile profile = selectedProfile.get(); if (profile != null && profile.getRepository() == event.getSource()) { selectedVersion.bind(profile.selectedVersionProperty()); for (Consumer listener : versionsListeners) listener.accept(profile); } }); }); } public static ObservableList getProfiles() { return profiles; } public static ReadOnlyListProperty profilesProperty() { return profilesWrapper.getReadOnlyProperty(); } public static Profile getSelectedProfile() { return selectedProfile.get(); } public static void setSelectedProfile(Profile profile) { selectedProfile.set(profile); } public static ObjectProperty selectedProfileProperty() { return selectedProfile; } private static final ReadOnlyStringWrapper selectedVersion = new ReadOnlyStringWrapper(); public static ReadOnlyStringProperty selectedVersionProperty() { return selectedVersion.getReadOnlyProperty(); } // Guaranteed that the repository is loaded. public static String getSelectedVersion() { return selectedVersion.get(); } private static final List> versionsListeners = new ArrayList<>(4); public static void registerVersionsListener(Consumer listener) { Profile profile = getSelectedProfile(); if (profile != null && profile.getRepository().isLoaded()) listener.accept(profile); versionsListeners.add(listener); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/ProxyManager.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import javafx.beans.InvalidationListener; import org.jackhuang.hmcl.task.FetchTask; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.net.*; import java.util.List; import java.util.Objects; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class ProxyManager { private static final SimpleProxySelector NO_PROXY = new SimpleProxySelector(Proxy.NO_PROXY); private static final ProxySelector SYSTEM_DEFAULT; static { ProxySelector systemProxySelector = ProxySelector.getDefault(); SYSTEM_DEFAULT = systemProxySelector != null ? new ProxySelectorWrapper(systemProxySelector) : NO_PROXY; } private static volatile @NotNull ProxySelector defaultProxySelector = SYSTEM_DEFAULT; private static volatile @Nullable SimpleAuthenticator defaultAuthenticator = null; private static ProxySelector getProxySelector() { if (config().hasProxy()) { Proxy.Type proxyType = config().getProxyType(); String host = config().getProxyHost(); int port = config().getProxyPort(); if (proxyType == Proxy.Type.DIRECT || StringUtils.isBlank(host)) { return NO_PROXY; } else if (port < 0 || port > 0xFFFF) { LOG.warning("Illegal proxy port: " + port); return NO_PROXY; } else { return new ProxySelectorWrapper(new SimpleProxySelector(new Proxy(proxyType, new InetSocketAddress(host, port)))); } } else { return ProxyManager.SYSTEM_DEFAULT; } } private static SimpleAuthenticator getAuthenticator() { if (config().hasProxy() && config().hasProxyAuth()) { String username = config().getProxyUser(); String password = config().getProxyPass(); if (username != null || password != null) return new SimpleAuthenticator( Objects.requireNonNullElse(username, ""), Objects.requireNonNullElse(password, "").toCharArray() ); else return null; } else return null; } static void init() { ProxySelector.setDefault(new ProxySelector() { @Override public List select(URI uri) { return defaultProxySelector.select(uri); } @Override public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { defaultProxySelector.connectFailed(uri, sa, ioe); } }); Authenticator.setDefault(new Authenticator() { @Override protected PasswordAuthentication getPasswordAuthentication() { var defaultAuthenticator = ProxyManager.defaultAuthenticator; return defaultAuthenticator != null ? defaultAuthenticator.getPasswordAuthentication() : null; } }); defaultProxySelector = getProxySelector(); InvalidationListener updateProxySelector = observable -> defaultProxySelector = getProxySelector(); config().proxyTypeProperty().addListener(updateProxySelector); config().proxyHostProperty().addListener(updateProxySelector); config().proxyPortProperty().addListener(updateProxySelector); config().hasProxyProperty().addListener(updateProxySelector); defaultAuthenticator = getAuthenticator(); InvalidationListener updateAuthenticator = observable -> defaultAuthenticator = getAuthenticator(); config().hasProxyProperty().addListener(updateAuthenticator); config().hasProxyAuthProperty().addListener(updateAuthenticator); config().proxyUserProperty().addListener(updateAuthenticator); config().proxyPassProperty().addListener(updateAuthenticator); FetchTask.notifyInitialized(); } private static abstract class AbstractProxySelector extends ProxySelector { @Override public void connectFailed(URI uri, SocketAddress sa, IOException ioe) { if (uri == null || sa == null || ioe == null) { throw new IllegalArgumentException("Arguments can't be null."); } } } private static final class SimpleProxySelector extends AbstractProxySelector { private final List proxies; SimpleProxySelector(Proxy proxy) { this.proxies = List.of(proxy); } @Override public List select(URI uri) { if (uri == null) throw new IllegalArgumentException("URI can't be null."); return proxies; } @Override public String toString() { return "SimpleProxySelector" + proxies; } } /// Wraps another ProxySelector to avoid using proxy for loopback addresses. private static final class ProxySelectorWrapper extends AbstractProxySelector { private final ProxySelector source; ProxySelectorWrapper(ProxySelector source) { this.source = source; } @Override public List select(URI uri) { if (uri == null) throw new IllegalArgumentException("URI can't be null."); if (NetworkUtils.isLoopbackAddress(uri)) return NO_PROXY.proxies; return source.select(uri); } } private static final class SimpleAuthenticator extends Authenticator { private final String username; private final char[] password; private SimpleAuthenticator(String username, char[] password) { this.username = username; this.password = password; } @Override public PasswordAuthentication getPasswordAuthentication() { return getRequestorType() == RequestorType.PROXY ? new PasswordAuthentication(username, password) : null; } } private ProxyManager() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/SambaException.java ================================================ package org.jackhuang.hmcl.setting; public final class SambaException extends RuntimeException { public SambaException() { } public SambaException(String message) { super(message); } public SambaException(String message, Throwable cause) { super(message, cause); } public SambaException(Throwable cause) { super(cause); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/Settings.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import javafx.beans.binding.Bindings; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.HMCLCacheRepository; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.util.CacheRepository; import org.jackhuang.hmcl.util.io.FileUtils; import static org.jackhuang.hmcl.setting.ConfigHolder.config; public final class Settings { private static Settings instance; public static Settings instance() { if (instance == null) { throw new IllegalStateException("Settings hasn't been initialized"); } return instance; } /** * Should be called from {@link ConfigHolder#init()}. */ static void init() { instance = new Settings(); } private Settings() { DownloadProviders.init(); ProxyManager.init(); Accounts.init(); Profiles.init(); AuthlibInjectorServers.init(); AnimationUtils.init(); CacheRepository.setInstance(HMCLCacheRepository.REPOSITORY); HMCLCacheRepository.REPOSITORY.directoryProperty().bind(Bindings.createStringBinding(() -> { if (FileUtils.canCreateDirectory(getCommonDirectory())) { return getCommonDirectory(); } else { return getDefaultCommonDirectory(); } }, config().commonDirectoryProperty(), config().commonDirTypeProperty())); } public static String getDefaultCommonDirectory() { return Metadata.MINECRAFT_DIRECTORY.toString(); } public String getCommonDirectory() { switch (config().getCommonDirType()) { case DEFAULT: return getDefaultCommonDirectory(); case CUSTOM: return config().getCommonDirectory(); default: return null; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/StyleSheets.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import javafx.beans.binding.Bindings; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.Scene; import javafx.scene.paint.Color; import org.glavo.monetfx.Brightness; import org.glavo.monetfx.ColorRole; import org.glavo.monetfx.ColorScheme; import org.jackhuang.hmcl.theme.Theme; import org.jackhuang.hmcl.theme.ThemeColor; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Base64; import java.util.Locale; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class StyleSheets { private static final int FONT_STYLE_SHEET_INDEX = 0; private static final int THEME_STYLE_SHEET_INDEX = 1; private static final int BRIGHTNESS_SHEET_INDEX = 2; private static final ObservableList stylesheets; static { String[] array = new String[]{ getFontStyleSheet(), getThemeStyleSheet(), getBrightnessStyleSheet(), "/assets/css/root.css" }; stylesheets = FXCollections.observableList(Arrays.asList(array)); FontManager.fontProperty().addListener(o -> stylesheets.set(FONT_STYLE_SHEET_INDEX, getFontStyleSheet())); Themes.colorSchemeProperty().addListener(o -> { stylesheets.set(THEME_STYLE_SHEET_INDEX, getThemeStyleSheet()); stylesheets.set(BRIGHTNESS_SHEET_INDEX, getBrightnessStyleSheet()); }); } private static String toStyleSheetUri(String styleSheet, String fallback) { if (FXUtils.JAVAFX_MAJOR_VERSION >= 17) // JavaFX 17+ support loading stylesheets from data URIs // https://bugs.openjdk.org/browse/JDK-8267554 return "data:text/css;charset=UTF-8;base64," + Base64.getEncoder().encodeToString(styleSheet.getBytes(StandardCharsets.UTF_8)); else try { Path temp = Files.createTempFile("hmcl", ".css"); // For JavaFX 17 or earlier, CssParser uses the default charset // https://bugs.openjdk.org/browse/JDK-8279328 Files.writeString(temp, styleSheet, Charset.defaultCharset()); temp.toFile().deleteOnExit(); return temp.toUri().toString(); } catch (IOException | NullPointerException e) { LOG.error("Unable to create stylesheet, fallback to " + fallback, e); return fallback; } } private static String getFontStyleSheet() { final String defaultCss = "/assets/css/font.css"; final FontManager.FontReference font = FontManager.getFont(); if (font == null || "System".equals(font.family())) return defaultCss; String fontFamily = font.family(); String style = font.style(); String weight = null; String posture = null; if (style != null) { style = style.toLowerCase(Locale.ROOT); if (style.contains("thin")) weight = "100"; else if (style.contains("extralight") || style.contains("extra light") || style.contains("ultralight") | style.contains("ultra light")) weight = "200"; else if (style.contains("medium")) weight = "500"; else if (style.contains("semibold") || style.contains("semi bold") || style.contains("demibold") || style.contains("demi bold")) weight = "600"; else if (style.contains("extrabold") || style.contains("extra bold") || style.contains("ultrabold") || style.contains("ultra bold")) weight = "800"; else if (style.contains("black") || style.contains("heavy")) weight = "900"; else if (style.contains("light")) weight = "lighter"; else if (style.contains("bold")) weight = "bold"; posture = style.contains("italic") || style.contains("oblique") ? "italic" : null; } StringBuilder builder = new StringBuilder(); builder.append(".root {"); builder.append("-fx-font-family:\"").append(fontFamily).append("\";"); if (weight != null) builder.append("-fx-font-weight:").append(weight).append(";"); if (posture != null) builder.append("-fx-font-style:").append(posture).append(";"); builder.append('}'); return toStyleSheetUri(builder.toString(), defaultCss); } private static String getBrightnessStyleSheet() { return Themes.getColorScheme().getBrightness() == Brightness.LIGHT ? "/assets/css/brightness-light.css" : "/assets/css/brightness-dark.css"; } private static void addColor(StringBuilder builder, String name, Color color) { builder.append(" ").append(name) .append(": ").append(ThemeColor.getColorDisplayName(color)).append(";\n"); } private static void addColor(StringBuilder builder, String name, Color color, double opacity) { builder.append(" ").append(name) .append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(color, opacity)).append(";\n"); } private static void addColor(StringBuilder builder, ColorScheme scheme, ColorRole role, double opacity) { builder.append(" ").append(role.getVariableName()).append("-transparent-%02d".formatted((int) (100 * opacity))) .append(": ").append(ThemeColor.getColorDisplayNameWithOpacity(scheme.getColor(role), opacity)) .append(";\n"); } private static String getThemeStyleSheet() { final String blueCss = "/assets/css/blue.css"; if (Theme.DEFAULT.equals(Themes.getTheme())) return blueCss; ColorScheme scheme = Themes.getColorScheme(); StringBuilder builder = new StringBuilder(); builder.append("* {\n"); for (ColorRole colorRole : ColorRole.ALL) { addColor(builder, colorRole.getVariableName(), scheme.getColor(colorRole)); } addColor(builder, "-monet-primary-seed", scheme.getPrimaryColorSeed()); addColor(builder, scheme, ColorRole.PRIMARY, 0.5); addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.5); addColor(builder, scheme, ColorRole.SURFACE, 0.5); addColor(builder, scheme, ColorRole.SURFACE, 0.8); addColor(builder, scheme, ColorRole.ON_SURFACE_VARIANT, 0.38); addColor(builder, scheme, ColorRole.SURFACE_CONTAINER_LOW, 0.8); addColor(builder, scheme, ColorRole.SECONDARY_CONTAINER, 0.8); addColor(builder, scheme, ColorRole.INVERSE_SURFACE, 0.8); builder.append("}\n"); return toStyleSheetUri(builder.toString(), blueCss); } public static void init(Scene scene) { Bindings.bindContent(scene.getStylesheets(), stylesheets); } private StyleSheets() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionIconType.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import javafx.scene.image.Image; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.ui.FXUtils; public enum VersionIconType { DEFAULT("/assets/img/grass.png"), GRASS("/assets/img/grass.png"), CHEST("/assets/img/chest.png"), CHICKEN("/assets/img/chicken.png"), COMMAND("/assets/img/command.png"), OPTIFINE("/assets/img/optifine.png"), CRAFT_TABLE("/assets/img/craft_table.png"), FABRIC("/assets/img/fabric.png"), FORGE("/assets/img/forge.png"), NEO_FORGE("/assets/img/neoforge.png"), FURNACE("/assets/img/furnace.png"), QUILT("/assets/img/quilt.png"), APRIL_FOOLS("/assets/img/april_fools.png"), CLEANROOM("/assets/img/cleanroom.png"), LEGACY_FABRIC("/assets/img/legacyfabric.png") ; // Please append new items at last public static VersionIconType getIconType(ModLoaderType modLoaderType) { return switch (modLoaderType) { case FORGE -> VersionIconType.FORGE; case NEO_FORGED -> VersionIconType.NEO_FORGE; case FABRIC -> VersionIconType.FABRIC; case QUILT -> VersionIconType.QUILT; case LITE_LOADER -> VersionIconType.CHICKEN; case CLEANROOM -> VersionIconType.CLEANROOM; default -> VersionIconType.COMMAND; }; } private final String resourceUrl; VersionIconType(String resourceUrl) { this.resourceUrl = resourceUrl; } public Image getIcon() { return FXUtils.newBuiltinImage(resourceUrl); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/setting/VersionSetting.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.setting; import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.property.*; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.javafx.ObservableHelper; import org.jackhuang.hmcl.util.javafx.PropertyUtils; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.platform.SystemInfo; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.io.IOException; import java.lang.reflect.Type; import java.nio.file.InvalidPathException; import java.nio.file.Paths; import java.util.*; import java.util.stream.Collectors; import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author huangyuhui */ @JsonAdapter(VersionSetting.Serializer.class) public final class VersionSetting implements Cloneable, Observable { private static final int SUGGESTED_MEMORY; static { double totalMemoryMB = MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()); SUGGESTED_MEMORY = totalMemoryMB >= 32768 ? 8192 : Integer.max((int) (Math.round(totalMemoryMB / 4.0 / 128.0) * 128), 256); } private final transient ObservableHelper helper = new ObservableHelper(this); public VersionSetting() { PropertyUtils.attachListener(this, helper); } private final BooleanProperty usesGlobalProperty = new SimpleBooleanProperty(this, "usesGlobal", true); public BooleanProperty usesGlobalProperty() { return usesGlobalProperty; } /** * HMCL Version Settings have been divided into 2 parts. * 1. Global settings. * 2. Version settings. * If a version claims that it uses global settings, its version setting will be disabled. *

* Defaults false because if one version uses global first, custom version file will not be generated. */ public boolean isUsesGlobal() { return usesGlobalProperty.get(); } public void setUsesGlobal(boolean usesGlobal) { usesGlobalProperty.set(usesGlobal); } // java private final ObjectProperty javaVersionTypeProperty = new SimpleObjectProperty<>(this, "javaVersionType", JavaVersionType.AUTO); public ObjectProperty javaVersionTypeProperty() { return javaVersionTypeProperty; } public JavaVersionType getJavaVersionType() { return javaVersionTypeProperty.get(); } public void setJavaVersionType(JavaVersionType javaVersionType) { javaVersionTypeProperty.set(javaVersionType); } private final StringProperty javaVersionProperty = new SimpleStringProperty(this, "javaVersion", ""); public StringProperty javaVersionProperty() { return javaVersionProperty; } public String getJavaVersion() { return javaVersionProperty.get(); } public void setJavaVersion(String java) { javaVersionProperty.set(java); } public void setUsesCustomJavaDir() { setJavaVersionType(JavaVersionType.CUSTOM); setJavaVersion(""); setDefaultJavaPath(null); } public void setJavaAutoSelected() { setJavaVersionType(JavaVersionType.AUTO); setJavaVersion(""); setDefaultJavaPath(null); } private final StringProperty defaultJavaPathProperty = new SimpleStringProperty(this, "defaultJavaPath", ""); /** * Path to Java executable, or null if user customizes java directory. * It's used to determine which JRE to use when multiple JREs match the selected Java version. */ public String getDefaultJavaPath() { return defaultJavaPathProperty.get(); } public StringProperty defaultJavaPathPropertyProperty() { return defaultJavaPathProperty; } public void setDefaultJavaPath(String defaultJavaPath) { defaultJavaPathProperty.set(defaultJavaPath); } /** * 0 - .minecraft/versions/<version>/natives/
*/ private final ObjectProperty nativesDirTypeProperty = new SimpleObjectProperty<>(this, "nativesDirType", NativesDirectoryType.VERSION_FOLDER); public ObjectProperty nativesDirTypeProperty() { return nativesDirTypeProperty; } public NativesDirectoryType getNativesDirType() { return nativesDirTypeProperty.get(); } public void setNativesDirType(NativesDirectoryType nativesDirType) { nativesDirTypeProperty.set(nativesDirType); } // Path to lwjgl natives directory private final StringProperty nativesDirProperty = new SimpleStringProperty(this, "nativesDirProperty", ""); public StringProperty nativesDirProperty() { return nativesDirProperty; } public String getNativesDir() { return nativesDirProperty.get(); } public void setNativesDir(String nativesDir) { nativesDirProperty.set(nativesDir); } private final StringProperty javaDirProperty = new SimpleStringProperty(this, "javaDir", ""); public StringProperty javaDirProperty() { return javaDirProperty; } /** * User customized java directory or null if user uses system Java. */ public String getJavaDir() { return javaDirProperty.get(); } public void setJavaDir(String javaDir) { javaDirProperty.set(javaDir); } private final StringProperty wrapperProperty = new SimpleStringProperty(this, "wrapper", ""); public StringProperty wrapperProperty() { return wrapperProperty; } /** * The command to launch java, i.e. optirun. */ public String getWrapper() { return wrapperProperty.get(); } public void setWrapper(String wrapper) { wrapperProperty.set(wrapper); } private final StringProperty permSizeProperty = new SimpleStringProperty(this, "permSize", ""); public StringProperty permSizeProperty() { return permSizeProperty; } /** * The permanent generation size of JVM garbage collection. */ public String getPermSize() { return permSizeProperty.get(); } public void setPermSize(String permSize) { permSizeProperty.set(permSize); } private final IntegerProperty maxMemoryProperty = new SimpleIntegerProperty(this, "maxMemory", SUGGESTED_MEMORY); public IntegerProperty maxMemoryProperty() { return maxMemoryProperty; } /** * The maximum memory/MB that JVM can allocate for heap. */ public int getMaxMemory() { return maxMemoryProperty.get(); } public void setMaxMemory(int maxMemory) { maxMemoryProperty.set(maxMemory); } /** * The minimum memory that JVM can allocate for heap. */ private final ObjectProperty minMemoryProperty = new SimpleObjectProperty<>(this, "minMemory", null); public ObjectProperty minMemoryProperty() { return minMemoryProperty; } public Integer getMinMemory() { return minMemoryProperty.get(); } public void setMinMemory(Integer minMemory) { minMemoryProperty.set(minMemory); } private final BooleanProperty autoMemory = new SimpleBooleanProperty(this, "autoMemory", true); public boolean isAutoMemory() { return autoMemory.get(); } public BooleanProperty autoMemoryProperty() { return autoMemory; } public void setAutoMemory(boolean autoMemory) { this.autoMemory.set(autoMemory); } private final StringProperty preLaunchCommandProperty = new SimpleStringProperty(this, "precalledCommand", ""); public StringProperty preLaunchCommandProperty() { return preLaunchCommandProperty; } /** * The command that will be executed before launching the Minecraft. * Operating system relevant. */ public String getPreLaunchCommand() { return preLaunchCommandProperty.get(); } public void setPreLaunchCommand(String preLaunchCommand) { preLaunchCommandProperty.set(preLaunchCommand); } private final StringProperty postExitCommand = new SimpleStringProperty(this, "postExitCommand", ""); public StringProperty postExitCommandProperty() { return postExitCommand; } /** * The command that will be executed after game exits. * Operating system relevant. */ public String getPostExitCommand() { return postExitCommand.get(); } public void setPostExitCommand(String postExitCommand) { this.postExitCommand.set(postExitCommand); } // options private final StringProperty javaArgsProperty = new SimpleStringProperty(this, "javaArgs", ""); public StringProperty javaArgsProperty() { return javaArgsProperty; } /** * The user customized arguments passed to JVM. */ public String getJavaArgs() { return javaArgsProperty.get(); } public void setJavaArgs(String javaArgs) { javaArgsProperty.set(javaArgs); } private final StringProperty minecraftArgsProperty = new SimpleStringProperty(this, "minecraftArgs", ""); public StringProperty minecraftArgsProperty() { return minecraftArgsProperty; } /** * The user customized arguments passed to Minecraft. */ public String getMinecraftArgs() { return minecraftArgsProperty.get(); } public void setMinecraftArgs(String minecraftArgs) { minecraftArgsProperty.set(minecraftArgs); } private final StringProperty environmentVariablesProperty = new SimpleStringProperty(this, "environmentVariables", ""); public StringProperty environmentVariablesProperty() { return environmentVariablesProperty; } public String getEnvironmentVariables() { return environmentVariablesProperty.get(); } public void setEnvironmentVariables(String env) { environmentVariablesProperty.set(env); } private final BooleanProperty noJVMArgsProperty = new SimpleBooleanProperty(this, "noJVMArgs", false); public BooleanProperty noJVMArgsProperty() { return noJVMArgsProperty; } /** * True if disallow HMCL use default JVM arguments. */ public boolean isNoJVMArgs() { return noJVMArgsProperty.get(); } public void setNoJVMArgs(boolean noJVMArgs) { noJVMArgsProperty.set(noJVMArgs); } private final BooleanProperty noOptimizingJVMArgsProperty = new SimpleBooleanProperty(this, "noOptimizingJVMArgs", false); public BooleanProperty noOptimizingJVMArgsProperty() { return noOptimizingJVMArgsProperty; } public boolean isNoOptimizingJVMArgs() { return noOptimizingJVMArgsProperty.get(); } public void setNoOptimizingJVMArgs(boolean noOptimizingJVMArgs) { noOptimizingJVMArgsProperty.set(noOptimizingJVMArgs); } private final BooleanProperty notCheckJVMProperty = new SimpleBooleanProperty(this, "notCheckJVM", false); public BooleanProperty notCheckJVMProperty() { return notCheckJVMProperty; } /** * True if HMCL does not check JVM validity. */ public boolean isNotCheckJVM() { return notCheckJVMProperty.get(); } public void setNotCheckJVM(boolean notCheckJVM) { notCheckJVMProperty.set(notCheckJVM); } private final BooleanProperty notCheckGameProperty = new SimpleBooleanProperty(this, "notCheckGame", false); public BooleanProperty notCheckGameProperty() { return notCheckGameProperty; } /** * True if HMCL does not check game's completeness. */ public boolean isNotCheckGame() { return notCheckGameProperty.get(); } public void setNotCheckGame(boolean notCheckGame) { notCheckGameProperty.set(notCheckGame); } private final BooleanProperty notPatchNativesProperty = new SimpleBooleanProperty(this, "notPatchNatives", false); public BooleanProperty notPatchNativesProperty() { return notPatchNativesProperty; } public boolean isNotPatchNatives() { return notPatchNativesProperty.get(); } public void setNotPatchNatives(boolean notPatchNatives) { notPatchNativesProperty.set(notPatchNatives); } private final BooleanProperty showLogsProperty = new SimpleBooleanProperty(this, "showLogs", false); public BooleanProperty showLogsProperty() { return showLogsProperty; } /** * True if show the logs after game launched. */ public boolean isShowLogs() { return showLogsProperty.get(); } public void setShowLogs(boolean showLogs) { showLogsProperty.set(showLogs); } private final BooleanProperty enableDebugLogOutputProperty = new SimpleBooleanProperty(this, "enableDebugLogOutput", false); public BooleanProperty enableDebugLogOutputProperty() { return enableDebugLogOutputProperty; } public boolean isEnableDebugLogOutput() { return enableDebugLogOutputProperty.get(); } public void setEnableDebugLogOutput(boolean u) { this.enableDebugLogOutputProperty.set(u); } // Minecraft settings. private final StringProperty serverIpProperty = new SimpleStringProperty(this, "serverIp", ""); public StringProperty serverIpProperty() { return serverIpProperty; } /** * The server ip that will be entered after Minecraft successfully loaded ly. *

* Format: ip:port or without port. */ public String getServerIp() { return serverIpProperty.get(); } public void setServerIp(String serverIp) { serverIpProperty.set(serverIp); } private final BooleanProperty fullscreenProperty = new SimpleBooleanProperty(this, "fullscreen", false); public BooleanProperty fullscreenProperty() { return fullscreenProperty; } /** * True if Minecraft started in fullscreen mode. */ public boolean isFullscreen() { return fullscreenProperty.get(); } public void setFullscreen(boolean fullscreen) { fullscreenProperty.set(fullscreen); } private final IntegerProperty widthProperty = new SimpleIntegerProperty(this, "width", 854); public IntegerProperty widthProperty() { return widthProperty; } /** * The width of Minecraft window, defaults 800. *

* The field saves int value. * String type prevents unexpected value from JsonParseException. * We can only reset this field instead of recreating the whole setting file. */ public int getWidth() { return widthProperty.get(); } public void setWidth(int width) { widthProperty.set(width); } private final IntegerProperty heightProperty = new SimpleIntegerProperty(this, "height", 480); public IntegerProperty heightProperty() { return heightProperty; } /** * The height of Minecraft window, defaults 480. *

* The field saves int value. * String type prevents unexpected value from JsonParseException. * We can only reset this field instead of recreating the whole setting file. */ public int getHeight() { return heightProperty.get(); } public void setHeight(int height) { heightProperty.set(height); } /** * 0 - .minecraft
* 1 - .minecraft/versions/<version>/
*/ private final ObjectProperty gameDirTypeProperty = new SimpleObjectProperty<>(this, "gameDirType", GameDirectoryType.ROOT_FOLDER); public ObjectProperty gameDirTypeProperty() { return gameDirTypeProperty; } public GameDirectoryType getGameDirType() { return gameDirTypeProperty.get(); } public void setGameDirType(GameDirectoryType gameDirType) { gameDirTypeProperty.set(gameDirType); } /** * Your custom gameDir */ private final StringProperty gameDirProperty = new SimpleStringProperty(this, "gameDir", ""); public StringProperty gameDirProperty() { return gameDirProperty; } public String getGameDir() { return gameDirProperty.get(); } public void setGameDir(String gameDir) { gameDirProperty.set(gameDir); } private final ObjectProperty processPriorityProperty = new SimpleObjectProperty<>(this, "processPriority", ProcessPriority.NORMAL); public ObjectProperty processPriorityProperty() { return processPriorityProperty; } public ProcessPriority getProcessPriority() { return processPriorityProperty.get(); } public void setProcessPriority(ProcessPriority processPriority) { processPriorityProperty.set(processPriority); } private final ObjectProperty rendererProperty = new SimpleObjectProperty<>(this, "renderer", Renderer.DEFAULT); public Renderer getRenderer() { return rendererProperty.get(); } public ObjectProperty rendererProperty() { return rendererProperty; } public void setRenderer(Renderer renderer) { this.rendererProperty.set(renderer); } private final BooleanProperty useNativeGLFW = new SimpleBooleanProperty(this, "nativeGLFW", false); public boolean isUseNativeGLFW() { return useNativeGLFW.get(); } public BooleanProperty useNativeGLFWProperty() { return useNativeGLFW; } public void setUseNativeGLFW(boolean useNativeGLFW) { this.useNativeGLFW.set(useNativeGLFW); } private final BooleanProperty useNativeOpenAL = new SimpleBooleanProperty(this, "nativeOpenAL", false); public boolean isUseNativeOpenAL() { return useNativeOpenAL.get(); } public BooleanProperty useNativeOpenALProperty() { return useNativeOpenAL; } public void setUseNativeOpenAL(boolean useNativeOpenAL) { this.useNativeOpenAL.set(useNativeOpenAL); } private final ObjectProperty versionIcon = new SimpleObjectProperty<>(this, "versionIcon", VersionIconType.DEFAULT); public VersionIconType getVersionIcon() { return versionIcon.get(); } public ObjectProperty versionIconProperty() { return versionIcon; } public void setVersionIcon(VersionIconType versionIcon) { this.versionIcon.set(versionIcon); } // launcher settings /** * 0 - Close the launcher when the game starts.
* 1 - Hide the launcher when the game starts.
* 2 - Keep the launcher open.
*/ private final ObjectProperty launcherVisibilityProperty = new SimpleObjectProperty<>(this, "launcherVisibility", LauncherVisibility.HIDE); public ObjectProperty launcherVisibilityProperty() { return launcherVisibilityProperty; } public LauncherVisibility getLauncherVisibility() { return launcherVisibilityProperty.get(); } public void setLauncherVisibility(LauncherVisibility launcherVisibility) { launcherVisibilityProperty.set(launcherVisibility); } public JavaRuntime getJava(GameVersionNumber gameVersion, Version version) throws InterruptedException { switch (getJavaVersionType()) { case DEFAULT: return JavaRuntime.getDefault(); case AUTO: return JavaManager.findSuitableJava(gameVersion, version); case CUSTOM: try { return JavaManager.getJava(Paths.get(getJavaDir())); } catch (IOException | InvalidPathException e) { return null; // Custom Java not found } case VERSION: { String javaVersion = getJavaVersion(); if (StringUtils.isBlank(javaVersion)) { return JavaManager.findSuitableJava(gameVersion, version); } int majorVersion = -1; try { majorVersion = Integer.parseInt(javaVersion); } catch (NumberFormatException ignored) { } if (majorVersion < 0) { LOG.warning("Invalid Java version: " + javaVersion); return null; } final int finalMajorVersion = majorVersion; Collection allJava = JavaManager.getAllJava().stream() .filter(it -> it.getParsedVersion() == finalMajorVersion) .collect(Collectors.toList()); return JavaManager.findSuitableJava(allJava, gameVersion, version); } case DETECTED: { String javaVersion = getJavaVersion(); if (StringUtils.isBlank(javaVersion)) { return JavaManager.findSuitableJava(gameVersion, version); } try { String defaultJavaPath = getDefaultJavaPath(); if (StringUtils.isNotBlank(defaultJavaPath)) { JavaRuntime java = JavaManager.getJava(Paths.get(defaultJavaPath).toRealPath()); if (java != null && java.getVersion().equals(javaVersion)) { return java; } } } catch (IOException | InvalidPathException ignored) { } for (JavaRuntime java : JavaManager.getAllJava()) { if (java.getVersion().equals(javaVersion)) { return java; } } return null; } default: throw new AssertionError("JavaVersionType: " + getJavaVersionType()); } } @Override public void addListener(InvalidationListener listener) { helper.addListener(listener); } @Override public void removeListener(InvalidationListener listener) { helper.removeListener(listener); } @Override public VersionSetting clone() { VersionSetting cloned = new VersionSetting(); PropertyUtils.copyProperties(this, cloned); return cloned; } public static class Serializer implements JsonSerializer, JsonDeserializer { @Override public JsonElement serialize(VersionSetting src, Type typeOfSrc, JsonSerializationContext context) { if (src == null) return JsonNull.INSTANCE; JsonObject obj = new JsonObject(); obj.addProperty("usesGlobal", src.isUsesGlobal()); obj.addProperty("javaArgs", src.getJavaArgs()); obj.addProperty("minecraftArgs", src.getMinecraftArgs()); obj.addProperty("environmentVariables", src.getEnvironmentVariables()); obj.addProperty("maxMemory", src.getMaxMemory() <= 0 ? SUGGESTED_MEMORY : src.getMaxMemory()); obj.addProperty("minMemory", src.getMinMemory()); obj.addProperty("autoMemory", src.isAutoMemory()); obj.addProperty("permSize", src.getPermSize()); obj.addProperty("width", src.getWidth()); obj.addProperty("height", src.getHeight()); obj.addProperty("javaDir", src.getJavaDir()); obj.addProperty("precalledCommand", src.getPreLaunchCommand()); obj.addProperty("postExitCommand", src.getPostExitCommand()); obj.addProperty("serverIp", src.getServerIp()); obj.addProperty("wrapper", src.getWrapper()); obj.addProperty("fullscreen", src.isFullscreen()); obj.addProperty("noJVMArgs", src.isNoJVMArgs()); obj.addProperty("noOptimizingJVMArgs", src.isNoOptimizingJVMArgs()); obj.addProperty("notCheckGame", src.isNotCheckGame()); obj.addProperty("notCheckJVM", src.isNotCheckJVM()); obj.addProperty("notPatchNatives", src.isNotPatchNatives()); obj.addProperty("showLogs", src.isShowLogs()); obj.addProperty("enableDebugLogOutput", src.isEnableDebugLogOutput()); obj.addProperty("gameDir", src.getGameDir()); obj.addProperty("launcherVisibility", src.getLauncherVisibility().ordinal()); obj.addProperty("processPriority", src.getProcessPriority().ordinal()); obj.addProperty("useNativeGLFW", src.isUseNativeGLFW()); obj.addProperty("useNativeOpenAL", src.isUseNativeOpenAL()); obj.addProperty("gameDirType", src.getGameDirType().ordinal()); obj.addProperty("defaultJavaPath", src.getDefaultJavaPath()); obj.addProperty("nativesDir", src.getNativesDir()); obj.addProperty("nativesDirType", src.getNativesDirType().ordinal()); obj.addProperty("versionIcon", src.getVersionIcon().ordinal()); obj.addProperty("javaVersionType", src.getJavaVersionType().name()); String java; switch (src.getJavaVersionType()) { case DEFAULT: java = "Default"; break; case AUTO: java = "Auto"; break; case CUSTOM: java = "Custom"; break; default: java = src.getJavaVersion(); break; } obj.addProperty("java", java); obj.addProperty("renderer", src.getRenderer().name()); if (src.getRenderer() == Renderer.LLVMPIPE) obj.addProperty("useSoftwareRenderer", true); return obj; } @Override public VersionSetting deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { if (!(json instanceof JsonObject)) return null; JsonObject obj = (JsonObject) json; int maxMemoryN = parseJsonPrimitive(Optional.ofNullable(obj.get("maxMemory")).map(JsonElement::getAsJsonPrimitive).orElse(null), SUGGESTED_MEMORY); if (maxMemoryN <= 0) maxMemoryN = SUGGESTED_MEMORY; VersionSetting vs = new VersionSetting(); vs.setUsesGlobal(Optional.ofNullable(obj.get("usesGlobal")).map(JsonElement::getAsBoolean).orElse(false)); vs.setJavaArgs(Optional.ofNullable(obj.get("javaArgs")).map(JsonElement::getAsString).orElse("")); vs.setMinecraftArgs(Optional.ofNullable(obj.get("minecraftArgs")).map(JsonElement::getAsString).orElse("")); vs.setEnvironmentVariables(Optional.ofNullable(obj.get("environmentVariables")).map(JsonElement::getAsString).orElse("")); vs.setMaxMemory(maxMemoryN); vs.setMinMemory(Optional.ofNullable(obj.get("minMemory")).map(JsonElement::getAsInt).orElse(null)); vs.setAutoMemory(Optional.ofNullable(obj.get("autoMemory")).map(JsonElement::getAsBoolean).orElse(true)); vs.setPermSize(Optional.ofNullable(obj.get("permSize")).map(JsonElement::getAsString).orElse("")); vs.setWidth(Optional.ofNullable(obj.get("width")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0)); vs.setHeight(Optional.ofNullable(obj.get("height")).map(JsonElement::getAsJsonPrimitive).map(this::parseJsonPrimitive).orElse(0)); vs.setJavaDir(Optional.ofNullable(obj.get("javaDir")).map(JsonElement::getAsString).orElse("")); vs.setPreLaunchCommand(Optional.ofNullable(obj.get("precalledCommand")).map(JsonElement::getAsString).orElse("")); vs.setPostExitCommand(Optional.ofNullable(obj.get("postExitCommand")).map(JsonElement::getAsString).orElse("")); vs.setServerIp(Optional.ofNullable(obj.get("serverIp")).map(JsonElement::getAsString).orElse("")); vs.setWrapper(Optional.ofNullable(obj.get("wrapper")).map(JsonElement::getAsString).orElse("")); vs.setGameDir(Optional.ofNullable(obj.get("gameDir")).map(JsonElement::getAsString).orElse("")); vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse("")); vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNoOptimizingJVMArgs(Optional.ofNullable(obj.get("noOptimizingJVMArgs")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false)); vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false)); vs.setShowLogs(Optional.ofNullable(obj.get("showLogs")).map(JsonElement::getAsBoolean).orElse(false)); vs.setEnableDebugLogOutput(Optional.ofNullable(obj.get("enableDebugLogOutput")).map(JsonElement::getAsBoolean).orElse(false)); vs.setLauncherVisibility(parseJsonPrimitive(obj.getAsJsonPrimitive("launcherVisibility"), LauncherVisibility.class, LauncherVisibility.HIDE)); vs.setProcessPriority(parseJsonPrimitive(obj.getAsJsonPrimitive("processPriority"), ProcessPriority.class, ProcessPriority.NORMAL)); vs.setUseNativeGLFW(Optional.ofNullable(obj.get("useNativeGLFW")).map(JsonElement::getAsBoolean).orElse(false)); vs.setUseNativeOpenAL(Optional.ofNullable(obj.get("useNativeOpenAL")).map(JsonElement::getAsBoolean).orElse(false)); vs.setGameDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("gameDirType"), GameDirectoryType.class, GameDirectoryType.ROOT_FOLDER)); vs.setDefaultJavaPath(Optional.ofNullable(obj.get("defaultJavaPath")).map(JsonElement::getAsString).orElse(null)); vs.setNativesDirType(parseJsonPrimitive(obj.getAsJsonPrimitive("nativesDirType"), NativesDirectoryType.class, NativesDirectoryType.VERSION_FOLDER)); vs.setVersionIcon(parseJsonPrimitive(obj.getAsJsonPrimitive("versionIcon"), VersionIconType.class, VersionIconType.DEFAULT)); if (obj.get("javaVersionType") != null) { JavaVersionType javaVersionType = parseJsonPrimitive(obj.getAsJsonPrimitive("javaVersionType"), JavaVersionType.class, JavaVersionType.AUTO); vs.setJavaVersionType(javaVersionType); vs.setJavaVersion(Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse(null)); } else { String java = Optional.ofNullable(obj.get("java")).map(JsonElement::getAsString).orElse(""); switch (java) { case "Default": vs.setJavaVersionType(JavaVersionType.DEFAULT); break; case "Auto": vs.setJavaVersionType(JavaVersionType.AUTO); break; case "Custom": vs.setJavaVersionType(JavaVersionType.CUSTOM); break; default: vs.setJavaVersion(java); } } vs.setRenderer(Optional.ofNullable(obj.get("renderer")).map(JsonElement::getAsString) .flatMap(name -> { try { return Optional.of(Renderer.valueOf(name.toUpperCase(Locale.ROOT))); } catch (IllegalArgumentException ignored) { return Optional.empty(); } }).orElseGet(() -> { boolean useSoftwareRenderer = Optional.ofNullable(obj.get("useSoftwareRenderer")).map(JsonElement::getAsBoolean).orElse(false); return useSoftwareRenderer ? Renderer.LLVMPIPE : Renderer.DEFAULT; })); return vs; } private int parseJsonPrimitive(JsonPrimitive primitive) { return parseJsonPrimitive(primitive, 0); } private int parseJsonPrimitive(JsonPrimitive primitive, int defaultValue) { if (primitive == null) return defaultValue; else if (primitive.isNumber()) return primitive.getAsInt(); else return Lang.parseInt(primitive.getAsString(), defaultValue); } private > E parseJsonPrimitive(JsonPrimitive primitive, Class clazz, E defaultValue) { if (primitive == null) return defaultValue; else { E[] enumConstants = clazz.getEnumConstants(); if (primitive.isNumber()) { int index = primitive.getAsInt(); return index >= 0 && index < enumConstants.length ? enumConstants[index] : defaultValue; } else { String name = primitive.getAsString(); for (E enumConstant : enumConstants) { if (enumConstant.name().equalsIgnoreCase(name)) { return enumConstant; } } return defaultValue; } } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaBundle.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta; import kala.compress.archivers.tar.TarArchiveEntry; import org.jackhuang.hmcl.download.ArtifactMalformedException; import org.jackhuang.hmcl.task.FetchTask; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.io.ChecksumMismatchException; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.logging.Logger; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.tree.TarFileTree; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URI; import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.security.DigestInputStream; import java.security.DigestOutputStream; import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; import java.util.Map; public final class TerracottaBundle { private final Path root; private final List links; private final FileDownloadTask.IntegrityCheck hash; private final Map files; public TerracottaBundle(Path root, List links, FileDownloadTask.IntegrityCheck hash, Map files) { this.root = root; this.links = links; this.hash = hash; this.files = files; } public Task download(AbstractTerracottaProvider.DownloadContext context) { return Task.supplyAsync(() -> Files.createTempFile("terracotta-", ".tar.gz")) .thenComposeAsync(Schedulers.javafx(), pkg -> { FileDownloadTask download = new FileDownloadTask(links, pkg, hash) { @Override protected Context getContext(HttpResponse response, boolean checkETag, String bmclapiHash) throws IOException { FetchTask.Context delegate = super.getContext(response, checkETag, bmclapiHash); return new Context() { @Override public void withResult(boolean success) { delegate.withResult(success); } @Override public void write(byte[] buffer, int offset, int len) throws IOException { context.checkCancellation(); delegate.write(buffer, offset, len); } @Override public void close() throws IOException { if (isSuccess()) { context.checkCancellation(); } delegate.close(); } }; } }; context.bindProgress(download.progressProperty()); return download.thenSupplyAsync(() -> pkg); }); } public Task install(Path pkg) { return Task.runAsync(() -> { Files.createDirectories(root); FileUtils.cleanDirectory(root); try (TarFileTree tree = TarFileTree.open(pkg)) { for (Map.Entry entry : files.entrySet()) { String file = entry.getKey(); FileDownloadTask.IntegrityCheck check = entry.getValue(); Path path = root.resolve(file); TarArchiveEntry archive = tree.getEntry("/" + file); if (archive == null) { throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file)); } MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm()); try ( InputStream is = tree.getInputStream(archive); OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest) ) { is.transferTo(os); } String hash = HexFormat.of().formatHex(digest.digest()); if (!check.getChecksum().equalsIgnoreCase(hash)) { throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash); } switch (OperatingSystem.CURRENT_OS) { case LINUX, MACOS, FREEBSD -> Files.setPosixFilePermissions(path, FileUtils.parsePosixFilePermission(archive.getMode())); } } } }).whenComplete(exception -> { if (exception != null) { FileUtils.deleteDirectory(root); } }); } public Path locate(String file) { FileDownloadTask.IntegrityCheck check = files.get(file); if (check == null) { throw new AssertionError(String.format("Expecting %s file in terracotta bundle.", file)); } return root.resolve(file).toAbsolutePath(); } public AbstractTerracottaProvider.Status status() throws IOException { if (Files.exists(root) && isLocalBundleValid()) { return AbstractTerracottaProvider.Status.READY; } try { if (TerracottaMetadata.hasLegacyVersionFiles()) { return AbstractTerracottaProvider.Status.LEGACY_VERSION; } } catch (IOException e) { Logger.LOG.warning("Cannot determine whether legacy versions exist.", e); } return AbstractTerracottaProvider.Status.NOT_EXIST; } private boolean isLocalBundleValid() throws IOException { // FIXME: Make control flow clearer. long total = 0; byte[] buffer = new byte[8192]; for (Map.Entry entry : files.entrySet()) { Path path = root.resolve(entry.getKey()); FileDownloadTask.IntegrityCheck check = entry.getValue(); if (!Files.isReadable(path)) { return false; } MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm()); try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) { int n; while ((n = is.read(buffer)) >= 0) { total += n; if (total >= 50 * 1024 * 1024) { // >=50MB return false; } } } if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) { return false; } } return true; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaManager.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta; import com.google.gson.JsonObject; import javafx.application.Platform; import javafx.beans.property.ReadOnlyDoubleWrapper; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.DownloadException; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FXThread; import org.jackhuang.hmcl.util.InvocationDispatcher; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.SystemUtils; import java.io.IOException; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class TerracottaManager { private TerracottaManager() { } private static final AtomicReference STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE); private static final ReadOnlyObjectWrapper STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain()); private static final InvocationDispatcher STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set); static { Schedulers.io().execute(() -> { try { if (TerracottaMetadata.PROVIDER == null) throw new IOException("Unsupported platform: " + org.jackhuang.hmcl.util.platform.Platform.CURRENT_PLATFORM); switch (TerracottaMetadata.PROVIDER.status()) { case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false)); case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true)); case READY -> launch(setState(new TerracottaState.Launching()), false); } } catch (Exception e) { LOG.warning("Cannot initialize Terracotta.", e); compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN)); } }); } public static ReadOnlyObjectProperty stateProperty() { return STATE.getReadOnlyProperty(); } private static final Thread DAEMON = Lang.thread(TerracottaManager::runBackground, "Terracotta Background Daemon", true); @FXThread // Written in FXThread, read-only on background daemon private static volatile boolean daemonRunning = false; private static void runBackground() { final long ACTIVE = TimeUnit.MILLISECONDS.toNanos(500); final long BACKGROUND = TimeUnit.SECONDS.toMillis(15); while (true) { if (daemonRunning) { LockSupport.parkNanos(ACTIVE); } else { long deadline = System.currentTimeMillis() + BACKGROUND; do { LockSupport.parkUntil(deadline); } while (!daemonRunning && System.currentTimeMillis() < deadline - 100); } if (!(STATE_V.get() instanceof TerracottaState.PortSpecific state)) { continue; } int port = state.port; int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE; TerracottaState next; try { TerracottaState.Ready object = HttpRequest.GET(String.format("http://127.0.0.1:%d/state", port)) .retry(5) .getJson(TerracottaState.Ready.class); if (object.index <= index) { continue; } object.port = port; next = object; } catch (Exception e) { LOG.warning("Cannot fetch state from Terracotta.", e); next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA); } compareAndSet(state, next); } } @FXThread public static void switchDaemon(boolean active) { FXUtils.checkFxUserThread(); boolean dr = daemonRunning; if (dr != active) { daemonRunning = active; if (active) { LockSupport.unpark(DAEMON); } } } private static AbstractTerracottaProvider getProvider() { AbstractTerracottaProvider provider = TerracottaMetadata.PROVIDER; if (provider == null) { throw new AssertionError("Terracotta Provider must NOT be null."); } return provider; } public static boolean isInvalidBundle(Path file) { return !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME); } @FXThread public static TerracottaState.Preparing download() { FXUtils.checkFxUserThread(); TerracottaState state = STATE_V.get(); if (!(state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) ) { return null; } TerracottaState.Preparing preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), true); Task.composeAsync(() -> getProvider().download(preparing)) .thenComposeAsync(pkg -> { if (!preparing.requestInstallFence()) { return null; } return getProvider().install(pkg).thenRunAsync(() -> { TerracottaState.Launching launching = new TerracottaState.Launching(); if (compareAndSet(preparing, launching)) { launch(launching, true); } }); }).whenComplete(exception -> { if (exception instanceof CancellationException) { // no-op } else if (exception instanceof DownloadException) { compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK)); } else { compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL)); } }).start(); return compareAndSet(state, preparing) ? preparing : null; } @FXThread public static TerracottaState.Preparing install(Path bundle) { FXUtils.checkFxUserThread(); if (isInvalidBundle(bundle)) { return null; } TerracottaState state = STATE_V.get(); TerracottaState.Preparing preparing; if (state instanceof TerracottaState.Preparing previousPreparing && previousPreparing.requestInstallFence()) { preparing = previousPreparing; } else if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable()) { preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1), false); } else { return null; } Task.composeAsync(() -> getProvider().install(bundle)) .thenRunAsync(() -> { TerracottaState.Launching launching = new TerracottaState.Launching(); if (compareAndSet(preparing, launching)) { launch(launching, true); } }) .whenComplete(exception -> { compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL)); }).start(); return state != preparing && compareAndSet(state, preparing) ? preparing : null; } @FXThread public static TerracottaState recover() { FXUtils.checkFxUserThread(); TerracottaState state = STATE_V.get(); if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) { return null; } try { // FIXME: A temporary limit has been employed in TerracottaBundle#checkExisting, making // hash check accept 50MB at most. Calling it on JavaFX should be safe. return switch (getProvider().status()) { case NOT_EXIST, LEGACY_VERSION -> download(); case READY -> { TerracottaState.Launching launching = setState(new TerracottaState.Launching()); launch(launching, false); yield launching; } }; } catch (RuntimeException | IOException e) { LOG.warning("Cannot determine Terracotta state.", e); return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN)); } } private static void launch(TerracottaState.Launching state, boolean removeLegacy) { Task.supplyAsync(() -> { Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath(); ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path))); process.pumpInputStream(SystemUtils::onLogLine); process.pumpErrorStream(SystemUtils::onLogLine); long exitTime = -1; while (true) { if (Files.exists(path)) { JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class); return object.get("port").getAsInt(); } if (!process.isRunning()) { if (exitTime == -1) { exitTime = System.currentTimeMillis(); } else if (System.currentTimeMillis() - exitTime >= 10000) { throw new IllegalStateException(String.format("Process has exited for 10s, code = %s", process.getExitCode())); } } } }).whenComplete(Schedulers.javafx(), (port, exception) -> { TerracottaState next; if (exception == null) { next = new TerracottaState.Unknown(port); if (removeLegacy) { TerracottaMetadata.removeLegacyVersionFiles(); } } else { next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA); } compareAndSet(state, next); }).start(); } public static Task exportLogs() { if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) { return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port))) .setSignificance(Task.TaskSignificance.MINOR); } return Task.completed(null); } public static TerracottaState.Waiting setWaiting() { TerracottaState state = STATE_V.get(); if (state instanceof TerracottaState.PortSpecific portSpecific) { new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port))) .setSignificance(Task.TaskSignificance.MINOR) .start(); return new TerracottaState.Waiting(-1, -1, null); } return null; } private static String getPlayerName() { Account account = Accounts.getSelectedAccount(); return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous"); } public static TerracottaState.HostScanning setScanning() { TerracottaState state = STATE_V.get(); if (state instanceof TerracottaState.PortSpecific portSpecific) { Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch) .thenComposeAsync(nodes -> { List> query = new ArrayList<>(nodes.size() + 1); query.add(pair("player", getPlayerName())); for (URI node : nodes) { query.add(pair("public_nodes", node.toString())); } return new GetTask(NetworkUtils.withQuery( "http://127.0.0.1:%d/state/scanning".formatted(portSpecific.port), query )).setSignificance(Task.TaskSignificance.MINOR); }).start(); return new TerracottaState.HostScanning(-1, -1, null); } return null; } public static Task setGuesting(String room) { TerracottaState state = STATE_V.get(); if (state instanceof TerracottaState.PortSpecific portSpecific) { return Task.supplyAsync(Schedulers.io(), TerracottaNodeList::fetch) .thenComposeAsync(nodes -> { ArrayList> query = new ArrayList<>(nodes.size() + 2); query.add(pair("room", room)); query.add(pair("player", getPlayerName())); for (URI node : nodes) { query.add(pair("public_nodes", node.toString())); } return new GetTask(NetworkUtils.withQuery("http://127.0.0.1:%d/state/guesting".formatted(portSpecific.port), query)) .setSignificance(Task.TaskSignificance.MINOR) .thenSupplyAsync(() -> new TerracottaState.GuestConnecting(-1, -1, null)) .setSignificance(Task.TaskSignificance.MINOR); }); } else { return null; } } private static T setState(T value) { if (value == null) { throw new AssertionError(); } STATE_V.set(value); STATE_D.accept(value); return value; } private static boolean compareAndSet(TerracottaState previous, TerracottaState next) { if (next == null) { throw new AssertionError(); } if (STATE_V.compareAndSet(previous, next)) { STATE_D.accept(next); return true; } else { return false; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaMetadata.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta; import com.google.gson.annotations.SerializedName; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; import org.jackhuang.hmcl.terracotta.provider.GeneralProvider; import org.jackhuang.hmcl.terracotta.provider.MacOSProvider; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.i18n.LocalizedText; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OSVersion; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jackhuang.hmcl.util.versioning.VersionRange; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class TerracottaMetadata { private TerracottaMetadata() { } private record Options(String version, String classifier) { public String replace(String value) { return value.replace("${version}", version).replace("${classifier}", classifier); } } @JsonSerializable public record Link( @SerializedName("desc") LocalizedText description, @SerializedName("link") String link ) { } @JsonSerializable private record Package( @SerializedName("hash") String hash, @SerializedName("files") Map files ) { } @JsonSerializable private record Config( @SerializedName("version_latest") String latest, @SerializedName("packages") Map pkgs, @SerializedName("downloads") List downloads, @SerializedName("downloads_CN") List downloadsCN, @SerializedName("links") List links ) { private @Nullable TerracottaBundle resolve(Options options) { Package pkg = pkgs.get(options.classifier); if (pkg == null) { return null; } Stream stream = downloads.stream(), streamCN = downloadsCN.stream(); List links = (LocaleUtils.IS_CHINA_MAINLAND ? Stream.concat(streamCN, stream) : Stream.concat(stream, streamCN)) .map(link -> URI.create(options.replace(link))) .toList(); Map files = pkg.files.entrySet().stream().collect(Collectors.toUnmodifiableMap( Map.Entry::getKey, entry -> new FileDownloadTask.IntegrityCheck("SHA-512", entry.getValue()) )); return new TerracottaBundle( Metadata.DEPENDENCIES_DIRECTORY.resolve(options.replace("terracotta/${version}")).toAbsolutePath(), links, new FileDownloadTask.IntegrityCheck("SHA-512", pkg.hash), files ); } } public static final AbstractTerracottaProvider PROVIDER; public static final String PACKAGE_NAME; public static final List PACKAGE_LINKS; public static final String FEEDBACK_LINK = NetworkUtils.withQuery("https://docs.hmcl.net/multiplayer/feedback.html", Map.of( "v", "v1", "launcher_version", Metadata.VERSION )); private static final String LATEST; static { Config config; try (InputStream is = TerracottaMetadata.class.getResourceAsStream("/assets/terracotta.json")) { config = JsonUtils.fromNonNullJsonFully(is, Config.class); } catch (IOException e) { throw new ExceptionInInitializerError(e); } LATEST = config.latest; Options options = new Options(config.latest, OperatingSystem.CURRENT_OS.getCheckedName() + "-" + Architecture.SYSTEM_ARCH.getCheckedName()); TerracottaBundle bundle = config.resolve(options); AbstractTerracottaProvider provider; if (bundle == null || (provider = locateProvider(bundle, options)) == null) { PROVIDER = null; PACKAGE_NAME = null; PACKAGE_LINKS = null; } else { PROVIDER = provider; PACKAGE_NAME = options.replace("terracotta-${version}-${classifier}-pkg.tar.gz"); List packageLinks = config.links.stream() .map(link -> new Link(link.description, options.replace(link.link))) .collect(Collectors.toList()); Collections.shuffle(packageLinks); PACKAGE_LINKS = Collections.unmodifiableList(packageLinks); } } @Nullable private static AbstractTerracottaProvider locateProvider(TerracottaBundle bundle, Options options) { String prefix = options.replace("terracotta-${version}-${classifier}"); // FIXME: As HMCL is a cross-platform application, developers may mistakenly locate // non-existent files in non-native platform logic without assertion errors during debugging. return switch (OperatingSystem.CURRENT_OS) { case WINDOWS -> { if (!OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_10)) yield null; yield new GeneralProvider(bundle, bundle.locate(prefix + ".exe")); } case LINUX, FREEBSD -> new GeneralProvider(bundle, bundle.locate(prefix)); case MACOS -> new MacOSProvider( bundle, bundle.locate(prefix), bundle.locate(prefix + ".pkg") ); default -> null; }; } public static void removeLegacyVersionFiles() { try (DirectoryStream terracotta = collectLegacyVersionFiles()) { if (terracotta == null) return; for (Path path : terracotta) { try { FileUtils.deleteDirectory(path); } catch (IOException e) { LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e); } } } catch (IOException e) { LOG.warning("Unable to remove legacy terracotta files.", e); } } public static boolean hasLegacyVersionFiles() throws IOException { try (DirectoryStream terracotta = collectLegacyVersionFiles()) { return terracotta != null && terracotta.iterator().hasNext(); } } private static @Nullable DirectoryStream collectLegacyVersionFiles() throws IOException { Path terracottaDir = Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta"); if (Files.notExists(terracottaDir)) return null; VersionRange range = VersionNumber.atMost(LATEST); return Files.newDirectoryStream(terracottaDir, path -> { String name = FileUtils.getName(path); return !LATEST.equals(name) && range.contains(VersionNumber.asVersion(name)); }); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaNodeList.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta; import com.google.gson.JsonParseException; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.TolerableValidationException; import org.jackhuang.hmcl.util.gson.Validation; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.HttpRequest; import org.jetbrains.annotations.Nullable; import java.net.URI; import java.net.URISyntaxException; import java.util.List; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// @author Glavo public final class TerracottaNodeList { private static final String NODE_LIST_URL = "https://terracotta.glavo.site/nodes"; @JsonSerializable private record TerracottaNode(String url, @Nullable String region) implements Validation { @Override public void validate() throws JsonParseException, TolerableValidationException { Validation.requireNonNull(url, "TerracottaNode.url cannot be null"); try { new URI(url); } catch (URISyntaxException e) { throw new JsonParseException("Invalid URL: " + url, e); } } } private static volatile List list; public static List fetch() { List list = TerracottaNodeList.list; if (list != null) return list; synchronized (TerracottaNodeList.class) { list = TerracottaNodeList.list; if (list != null) return list; try { List nodes = HttpRequest.GET(NODE_LIST_URL) .getJson(JsonUtils.listTypeOf(TerracottaNode.class)); if (nodes == null) { list = List.of(); LOG.info("No available Terracotta nodes found"); } else { list = nodes.stream() .filter(node -> { if (node == null) return false; try { node.validate(); } catch (Exception e) { LOG.warning("Invalid terracotta node: " + node, e); return false; } return StringUtils.isBlank(node.region) || LocaleUtils.IS_CHINA_MAINLAND == "CN".equalsIgnoreCase(node.region); }) .map(it -> URI.create(it.url())) .toList(); LOG.info("Terracotta node list: " + list); } } catch (Exception e) { LOG.warning("Failed to fetch terracotta node list", e); list = List.of(); } TerracottaNodeList.list = list; return list; } } private TerracottaNodeList() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/TerracottaState.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta; import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; import javafx.beans.property.ReadOnlyDoubleProperty; import javafx.beans.property.ReadOnlyDoubleWrapper; import javafx.beans.value.ObservableDoubleValue; import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile; import org.jackhuang.hmcl.terracotta.provider.AbstractTerracottaProvider; import org.jackhuang.hmcl.util.gson.JsonSubtype; import org.jackhuang.hmcl.util.gson.JsonType; import org.jackhuang.hmcl.util.gson.TolerableValidationException; import org.jackhuang.hmcl.util.gson.Validation; import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.atomic.AtomicBoolean; public abstract sealed class TerracottaState { protected TerracottaState() { } public boolean isUIFakeState() { return false; } public boolean isForkOf(TerracottaState state) { return false; } public static final class Bootstrap extends TerracottaState { static final Bootstrap INSTANCE = new Bootstrap(); private Bootstrap() { } } public static final class Uninitialized extends TerracottaState { private final boolean hasLegacy; Uninitialized(boolean hasLegacy) { this.hasLegacy = hasLegacy; } public boolean hasLegacy() { return hasLegacy; } } public static final class Preparing extends TerracottaState implements AbstractTerracottaProvider.DownloadContext { private final ReadOnlyDoubleWrapper progress; private final AtomicBoolean hasInstallFence; Preparing(ReadOnlyDoubleWrapper progress, boolean hasInstallFence) { this.progress = progress; this.hasInstallFence = new AtomicBoolean(hasInstallFence); } public ReadOnlyDoubleProperty progressProperty() { return progress.getReadOnlyProperty(); } public boolean requestInstallFence() { return hasInstallFence.compareAndSet(true, false); } public boolean hasInstallFence() { return hasInstallFence.get(); } @Override public void bindProgress(ObservableDoubleValue progress) { this.progress.bind(progress); } @Override public void checkCancellation() { if (!hasInstallFence()) { throw new CancellationException("User has installed terracotta from local archives."); } } } public static final class Launching extends TerracottaState { Launching() { } } static abstract sealed class PortSpecific extends TerracottaState { transient int port; protected PortSpecific(int port) { this.port = port; } } @JsonType( property = "state", subtypes = { @JsonSubtype(clazz = Waiting.class, name = "waiting"), @JsonSubtype(clazz = HostScanning.class, name = "host-scanning"), @JsonSubtype(clazz = HostStarting.class, name = "host-starting"), @JsonSubtype(clazz = HostOK.class, name = "host-ok"), @JsonSubtype(clazz = GuestConnecting.class, name = "guest-connecting"), @JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"), @JsonSubtype(clazz = GuestOK.class, name = "guest-ok"), @JsonSubtype(clazz = Exception.class, name = "exception"), } ) static abstract sealed class Ready extends PortSpecific { @SerializedName("index") final int index; @SerializedName("state") private final String state; Ready(int port, int index, String state) { super(port); this.index = index; this.state = state; } @Override public boolean isUIFakeState() { return this.index == -1; } } public static final class Unknown extends PortSpecific { Unknown(int port) { super(port); } } public static final class Waiting extends Ready { Waiting(int port, int index, String state) { super(port, index, state); } } public static final class HostScanning extends Ready { HostScanning(int port, int index, String state) { super(port, index, state); } } public static final class HostStarting extends Ready { HostStarting(int port, int index, String state) { super(port, index, state); } } public static final class HostOK extends Ready implements Validation { @SerializedName("room") private final String code; @SerializedName("profile_index") private final int profileIndex; @SerializedName("profiles") private final List profiles; HostOK(int port, int index, String state, String code, int profileIndex, List profiles) { super(port, index, state); this.code = code; this.profileIndex = profileIndex; this.profiles = profiles; } @Override public void validate() throws JsonParseException, TolerableValidationException { if (code == null) { throw new JsonParseException("code is null"); } if (profiles == null) { throw new JsonParseException("profiles is null"); } } public String getCode() { return code; } public List getProfiles() { return profiles; } @Override public boolean isForkOf(TerracottaState state) { return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex; } } public static final class GuestConnecting extends Ready { GuestConnecting(int port, int index, String state) { super(port, index, state); } } public static final class GuestStarting extends Ready { public enum Difficulty { UNKNOWN, EASIEST, SIMPLE, MEDIUM, TOUGH } @SerializedName("difficulty") private final Difficulty difficulty; GuestStarting(int port, int index, String state, Difficulty difficulty) { super(port, index, state); this.difficulty = difficulty; } public Difficulty getDifficulty() { return difficulty; } } public static final class GuestOK extends Ready implements Validation { @SerializedName("url") private final String url; @SerializedName("profile_index") private final int profileIndex; @SerializedName("profiles") private final List profiles; GuestOK(int port, int index, String state, String url, int profileIndex, List profiles) { super(port, index, state); this.url = url; this.profileIndex = profileIndex; this.profiles = profiles; } @Override public void validate() throws JsonParseException, TolerableValidationException { if (profiles == null) { throw new JsonParseException("profiles is null"); } } public String getUrl() { return url; } public List getProfiles() { return profiles; } @Override public boolean isForkOf(TerracottaState state) { return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex; } } public static final class Exception extends Ready implements Validation { public enum Type { PING_HOST_FAIL, PING_HOST_RST, GUEST_ET_CRASH, HOST_ET_CRASH, PING_SERVER_RST, SCAFFOLDING_INVALID_RESPONSE } private static final TerracottaState.Exception.Type[] LOOKUP = Type.values(); @SerializedName("type") private final int type; Exception(int port, int index, String state, int type) { super(port, index, state); this.type = type; } @Override public void validate() throws JsonParseException, TolerableValidationException { if (type < 0 || type >= LOOKUP.length) { throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length)); } } public Type getType() { return LOOKUP[type]; } } public static final class Fatal extends TerracottaState { public enum Type { OS, NETWORK, INSTALL, TERRACOTTA, UNKNOWN; } private final Type type; public Fatal(Type type) { this.type = type; } public Type getType() { return type; } public boolean isRecoverable() { return this.type != Type.UNKNOWN; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/profile/ProfileKind.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta.profile; import com.google.gson.annotations.SerializedName; public enum ProfileKind { @SerializedName("HOST") HOST, @SerializedName("LOCAL") LOCAL, @SerializedName("GUEST") GUEST } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/profile/TerracottaProfile.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta.profile; import com.google.gson.annotations.SerializedName; public final class TerracottaProfile { @SerializedName("machine_id") private final String machineID; @SerializedName("name") private final String name; @SerializedName("vendor") private final String vendor; @SerializedName("kind") private final ProfileKind type; private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) { this.machineID = machineID; this.name = name; this.vendor = vendor; this.type = type; } public String getMachineID() { return machineID; } public String getName() { return name; } public String getVendor() { return vendor; } public ProfileKind getType() { return type; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/AbstractTerracottaProvider.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta.provider; import javafx.beans.value.ObservableDoubleValue; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.terracotta.TerracottaBundle; import org.jackhuang.hmcl.util.FXThread; import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.concurrent.CancellationException; public abstract class AbstractTerracottaProvider { public enum Status { NOT_EXIST, LEGACY_VERSION, READY } public interface DownloadContext { @FXThread void bindProgress(ObservableDoubleValue progress); void checkCancellation() throws CancellationException; } protected final TerracottaBundle bundle; protected AbstractTerracottaProvider(TerracottaBundle bundle) { this.bundle = bundle; } public Status status() throws IOException { return bundle.status(); } public final Task download(DownloadContext progress) { return bundle.download(progress); } public Task install(Path pkg) throws IOException { return bundle.install(pkg); } public abstract List ofCommandLine(Path portTransfer); } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/GeneralProvider.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta.provider; import org.jackhuang.hmcl.terracotta.TerracottaBundle; import java.nio.file.Path; import java.util.List; public final class GeneralProvider extends AbstractTerracottaProvider { private final Path executable; public GeneralProvider(TerracottaBundle bundle, Path executable) { super(bundle); this.executable = executable; } @Override public List ofCommandLine(Path portTransfer) { return List.of(executable.toString(), "--hmcl", portTransfer.toString()); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/terracotta/provider/MacOSProvider.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.terracotta.provider; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.terracotta.TerracottaBundle; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.SystemUtils; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.List; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class MacOSProvider extends AbstractTerracottaProvider { private final Path executable, installer; public MacOSProvider(TerracottaBundle bundle, Path executable, Path installer) { super(bundle); this.executable = executable; this.installer = installer; } @Override public Status status() throws IOException { if (!Files.exists(Path.of("/Applications/terracotta.app"))) { return Status.NOT_EXIST; } return bundle.status(); } @Override public Task install(Path pkg) throws IOException { return super.install(pkg).thenComposeAsync(() -> { Path osascript = SystemUtils.which("osascript"); if (osascript == null) { throw new IllegalStateException("Cannot locate 'osascript' system executable on MacOS for installing Terracotta."); } Path movedInstaller = Files.createTempDirectory(Metadata.HMCL_GLOBAL_DIRECTORY, "terracotta-pkg") .toRealPath() .resolve(FileUtils.getName(installer)); Files.copy(installer, movedInstaller, StandardCopyOption.REPLACE_EXISTING); ManagedProcess process = new ManagedProcess(new ProcessBuilder( osascript.toString(), "-e", String.format( "do shell script \"installer -pkg '%s' -target /\" with prompt \"%s\" with administrator privileges", movedInstaller, i18n("terracotta.sudo_installing") ))); process.pumpInputStream(SystemUtils::onLogLine); process.pumpErrorStream(SystemUtils::onLogLine); return Task.fromCompletableFuture(process.getProcess().onExit()).thenRunAsync(() -> { try { FileUtils.cleanDirectory(movedInstaller.getParent()); } catch (IOException e) { LOG.warning("Cannot remove temporary Terracotta package file.", e); } if (process.getExitCode() != 0) { throw new IllegalStateException(String.format( "Cannot install Terracotta %s: system installer exited with code %d", movedInstaller, process.getExitCode() )); } }); }); } @Override public List ofCommandLine(Path path) { return List.of(executable.toString(), "--hmcl", path.toString()); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/theme/Theme.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.theme; import org.glavo.monetfx.*; import java.util.*; /// @author Glavo public final class Theme { public static final Theme DEFAULT = new Theme(ThemeColor.DEFAULT, Brightness.DEFAULT, ColorStyle.FIDELITY, Contrast.DEFAULT); private final ThemeColor primaryColorSeed; private final Brightness brightness; private final ColorStyle colorStyle; private final Contrast contrast; public Theme(ThemeColor primaryColorSeed, Brightness brightness, ColorStyle colorStyle, Contrast contrast ) { this.primaryColorSeed = primaryColorSeed; this.brightness = brightness; this.colorStyle = colorStyle; this.contrast = contrast; } public ColorScheme toColorScheme() { return ColorScheme.newBuilder() .setPrimaryColorSeed(primaryColorSeed.color()) .setColorStyle(colorStyle) .setBrightness(brightness) .setSpecVersion(ColorSpecVersion.SPEC_2025) .setContrast(contrast) .build(); } public ThemeColor primaryColorSeed() { return primaryColorSeed; } public Brightness brightness() { return brightness; } public ColorStyle colorStyle() { return colorStyle; } public Contrast contrast() { return contrast; } @Override public boolean equals(Object obj) { return obj == this || obj instanceof Theme that && this.primaryColorSeed.color().equals(that.primaryColorSeed.color()) && this.brightness.equals(that.brightness) && this.colorStyle.equals(that.colorStyle) && this.contrast.equals(that.contrast); } @Override public int hashCode() { return Objects.hash(primaryColorSeed, brightness, colorStyle, contrast); } @Override public String toString() { return "Theme[" + "primaryColorSeed=" + primaryColorSeed + ", " + "brightness=" + brightness + ", " + "colorStyle=" + colorStyle + ", " + "contrast=" + contrast + ']'; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/theme/ThemeColor.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.theme; import com.google.gson.annotations.JsonAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.WeakListener; import javafx.beans.property.Property; import javafx.scene.control.ColorPicker; import javafx.scene.paint.Color; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.lang.ref.WeakReference; import java.util.List; import java.util.Objects; /// @author Glavo @JsonAdapter(ThemeColor.TypeAdapter.class) @JsonSerializable public record ThemeColor(@NotNull String name, @NotNull Color color) { public static final ThemeColor DEFAULT = new ThemeColor("blue", Color.web("#5C6BC0")); public static final List STANDARD_COLORS = List.of( DEFAULT, new ThemeColor("darker_blue", Color.web("#283593")), new ThemeColor("green", Color.web("#43A047")), new ThemeColor("orange", Color.web("#E67E22")), new ThemeColor("purple", Color.web("#9C27B0")), new ThemeColor("red", Color.web("#B71C1C")) ); public static String getColorDisplayName(Color c) { return c != null ? String.format("#%02X%02X%02X", Math.round(c.getRed() * 255.0D), Math.round(c.getGreen() * 255.0D), Math.round(c.getBlue() * 255.0D)) : null; } public static String getColorDisplayNameWithOpacity(Color c, double opacity) { return c != null ? String.format("#%02X%02X%02X%02X", Math.round(c.getRed() * 255.0D), Math.round(c.getGreen() * 255.0D), Math.round(c.getBlue() * 255.0D), Math.round(opacity * 255.0)) : null; } public static @Nullable ThemeColor of(String name) { if (name == null) return null; if (!name.startsWith("#")) { for (ThemeColor color : STANDARD_COLORS) { if (name.equalsIgnoreCase(color.name())) return color; } } try { return new ThemeColor(name, Color.web(name)); } catch (IllegalArgumentException e) { return null; } } @Contract("null -> null; !null -> !null") public static ThemeColor of(Color color) { return color != null ? new ThemeColor(getColorDisplayName(color), color) : null; } private static final class BidirectionalBinding implements InvalidationListener, WeakListener { private final WeakReference colorPickerRef; private final WeakReference> propertyRef; private final int hashCode; private boolean updating = false; private BidirectionalBinding(ColorPicker colorPicker, Property property) { this.colorPickerRef = new WeakReference<>(colorPicker); this.propertyRef = new WeakReference<>(property); this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property); } @Override public void invalidated(Observable sourceProperty) { if (!updating) { final ColorPicker colorPicker = colorPickerRef.get(); final Property property = propertyRef.get(); if (colorPicker == null || property == null) { if (colorPicker != null) { colorPicker.valueProperty().removeListener(this); } if (property != null) { property.removeListener(this); } } else { updating = true; try { if (property == sourceProperty) { ThemeColor newValue = property.getValue(); colorPicker.setValue(newValue != null ? newValue.color() : null); } else { Color newValue = colorPicker.getValue(); property.setValue(newValue != null ? ThemeColor.of(newValue) : null); } } finally { updating = false; } } } } @Override public boolean wasGarbageCollected() { return colorPickerRef.get() == null || propertyRef.get() == null; } @Override public int hashCode() { return hashCode; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof BidirectionalBinding that)) return false; final ColorPicker colorPicker = this.colorPickerRef.get(); final Property property = this.propertyRef.get(); final ColorPicker thatColorPicker = that.colorPickerRef.get(); final Property thatProperty = that.propertyRef.get(); if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null) return false; return colorPicker == thatColorPicker && property == thatProperty; } } public static void bindBidirectional(ColorPicker colorPicker, Property property) { var binding = new BidirectionalBinding(colorPicker, property); colorPicker.valueProperty().removeListener(binding); property.removeListener(binding); ThemeColor themeColor = property.getValue(); colorPicker.setValue(themeColor != null ? themeColor.color() : null); colorPicker.valueProperty().addListener(binding); property.addListener(binding); } static final class TypeAdapter extends com.google.gson.TypeAdapter { @Override public void write(JsonWriter out, ThemeColor value) throws IOException { out.value(value.name()); } @Override public ThemeColor read(JsonReader in) throws IOException { return Objects.requireNonNullElse(of(in.nextString()), ThemeColor.DEFAULT); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/theme/Themes.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.theme; import com.sun.jna.Pointer; import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; import javafx.beans.binding.ObjectBinding; import javafx.beans.binding.ObjectExpression; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.event.EventHandler; import javafx.scene.paint.Color; import javafx.stage.Stage; import javafx.stage.WindowEvent; import org.glavo.monetfx.Brightness; import org.glavo.monetfx.ColorScheme; import org.glavo.monetfx.Contrast; import org.glavo.monetfx.beans.property.ColorSchemeProperty; import org.glavo.monetfx.beans.property.ReadOnlyColorSchemeProperty; import org.glavo.monetfx.beans.property.SimpleColorSchemeProperty; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.ui.WindowsNativeUtils; import org.jackhuang.hmcl.util.platform.NativeUtils; import org.jackhuang.hmcl.util.platform.OSVersion; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.SystemUtils; import org.jackhuang.hmcl.util.platform.windows.Dwmapi; import org.jackhuang.hmcl.util.platform.windows.WinConstants; import org.jackhuang.hmcl.util.platform.windows.WinReg; import org.jackhuang.hmcl.util.platform.windows.WinTypes; import java.nio.file.Path; import java.time.Duration; import java.util.*; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// @author Glavo public final class Themes { private static final ObjectExpression theme = new ObjectBinding<>() { { List observables = new ArrayList<>(); observables.add(config().themeBrightnessProperty()); observables.add(config().themeColorProperty()); if (FXUtils.DARK_MODE != null) { observables.add(FXUtils.DARK_MODE); } bind(observables.toArray(new Observable[0])); } private Brightness getBrightness() { String themeBrightness = config().getThemeBrightness(); if (themeBrightness == null) return Brightness.DEFAULT; return switch (themeBrightness.toLowerCase(Locale.ROOT).trim()) { case "auto" -> { if (FXUtils.DARK_MODE != null) { yield FXUtils.DARK_MODE.get() ? Brightness.DARK : Brightness.LIGHT; } else { yield getDefaultBrightness(); } } case "dark" -> Brightness.DARK; case "light" -> Brightness.LIGHT; default -> Brightness.DEFAULT; }; } @Override protected Theme computeValue() { ThemeColor themeColor = Objects.requireNonNullElse(config().getThemeColor(), ThemeColor.DEFAULT); return new Theme(themeColor, getBrightness(), Theme.DEFAULT.colorStyle(), Contrast.DEFAULT); } }; private static final ColorSchemeProperty colorScheme = new SimpleColorSchemeProperty(); private static final BooleanBinding darkMode = Bindings.createBooleanBinding( () -> colorScheme.get().getBrightness() == Brightness.DARK, colorScheme ); static { ChangeListener listener = (observable, oldValue, newValue) -> { if (!Objects.equals(oldValue, newValue)) { colorScheme.set(newValue != null ? newValue.toColorScheme() : Theme.DEFAULT.toColorScheme()); } }; listener.changed(theme, null, theme.get()); theme.addListener(listener); } private static Brightness defaultBrightness; private static Brightness getDefaultBrightness() { if (defaultBrightness != null) return defaultBrightness; LOG.info("Detecting system theme brightness"); Brightness brightness = Brightness.DEFAULT; if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { WinReg reg = WinReg.INSTANCE; if (reg != null) { Object appsUseLightTheme = reg.queryValue(WinReg.HKEY.HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "AppsUseLightTheme"); if (appsUseLightTheme instanceof Integer value) { brightness = value == 0 ? Brightness.DARK : Brightness.LIGHT; } } } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { try { String result = SystemUtils.run("/usr/bin/defaults", "read", "-g", "AppleInterfaceStyle").trim(); brightness = "Dark".equalsIgnoreCase(result) ? Brightness.DARK : Brightness.LIGHT; } catch (Exception e) { // If the key does not exist, it means Light mode is used brightness = Brightness.LIGHT; } } else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) { Path dbusSend = SystemUtils.which("dbus-send"); if (dbusSend != null) { try { String[] result = SystemUtils.run(List.of( FileUtils.getAbsolutePath(dbusSend), "--session", "--print-reply=literal", "--reply-timeout=1000", "--dest=org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings.Read", "string:org.freedesktop.appearance", "string:color-scheme" ), Duration.ofSeconds(2)).trim().split(" "); if (result.length > 0) { String value = result[result.length - 1]; // 1: prefer dark // 2: prefer light if ("1".equals(value)) { brightness = Brightness.DARK; } else if ("2".equals(value)) { brightness = Brightness.LIGHT; } } } catch (Exception e) { LOG.warning("Failed to get system theme from D-Bus", e); } } } LOG.info("Detected system theme brightness: " + brightness); return defaultBrightness = brightness; } public static ObjectExpression themeProperty() { return theme; } public static Theme getTheme() { return themeProperty().get(); } public static ReadOnlyColorSchemeProperty colorSchemeProperty() { return colorScheme; } public static ColorScheme getColorScheme() { return colorScheme.get(); } private static final ObjectBinding titleFill = Bindings.createObjectBinding( () -> config().isTitleTransparent() ? getColorScheme().getOnSurface() : getColorScheme().getOnPrimaryContainer(), colorSchemeProperty(), config().titleTransparentProperty() ); public static ObservableValue titleFillProperty() { return titleFill; } public static BooleanBinding darkModeProperty() { return darkMode; } public static void applyNativeDarkMode(Stage stage) { if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_11) && NativeUtils.USE_JNA && Dwmapi.INSTANCE != null) { ChangeListener listener = FXUtils.onWeakChange(Themes.darkModeProperty(), darkMode -> { if (stage.isShowing()) { WindowsNativeUtils.getWindowHandle(stage).ifPresent(handle -> { if (handle == WinTypes.HANDLE.INVALID_VALUE) return; Dwmapi.INSTANCE.DwmSetWindowAttribute( new WinTypes.HANDLE(Pointer.createConstant(handle)), WinConstants.DWMWA_USE_IMMERSIVE_DARK_MODE, new WinTypes.BOOLByReference(new WinTypes.BOOL(darkMode)), WinTypes.BOOL.SIZE ); }); } }); stage.getProperties().put("Themes.applyNativeDarkMode.listener", listener); if (stage.isShowing()) { listener.changed(null, false, Themes.darkModeProperty().get()); } else { stage.addEventFilter(WindowEvent.WINDOW_SHOWN, new EventHandler<>() { @Override public void handle(WindowEvent event) { stage.removeEventFilter(WindowEvent.WINDOW_SHOWN, this); listener.changed(null, false, Themes.darkModeProperty().get()); } }); } } } private Themes() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.validation.base.ValidatorBase; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ReadOnlyDoubleProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.geometry.Rectangle2D; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.ButtonBase; import javafx.scene.control.Label; import javafx.scene.layout.Region; import javafx.scene.paint.Color; import javafx.stage.Screen; import javafx.stage.Stage; import javafx.stage.StageStyle; import javafx.util.Duration; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.LauncherHelper; import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.account.AccountListPage; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.Motion; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; import org.jackhuang.hmcl.ui.decorator.DecoratorController; import org.jackhuang.hmcl.ui.download.DownloadPage; import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider; import org.jackhuang.hmcl.ui.main.LauncherSettingsPage; import org.jackhuang.hmcl.ui.main.RootPage; import org.jackhuang.hmcl.ui.terracotta.TerracottaPage; import org.jackhuang.hmcl.ui.versions.GameListPage; import org.jackhuang.hmcl.ui.versions.VersionPage; import org.jackhuang.hmcl.ui.versions.Versions; import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.SupportedLocale; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Path; import java.time.LocalDate; import java.util.List; import java.util.concurrent.CompletableFuture; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class Controllers { public static final String JAVA_VERSION_TIP = "javaVersion"; public static final String JAVA_INTERPRETED_MODE_TIP = "javaInterpretedMode"; public static final String SOFTWARE_RENDERING = "softwareRendering"; public static final String APRIL_FOOLS = "aprilFools"; public static final int MIN_WIDTH = 800 + 2 + 16; // bg width + border width*2 + shadow width*2 public static final int MIN_HEIGHT = 450 + 2 + 40 + 16; // bg height + border width*2 + toolbar height + shadow width*2 public static final Screen SCREEN = Screen.getPrimary(); private static InvalidationListener stageSizeChangeListener; private static DoubleProperty stageX = new SimpleDoubleProperty(); private static DoubleProperty stageY = new SimpleDoubleProperty(); private static DoubleProperty stageWidth = new SimpleDoubleProperty(); private static DoubleProperty stageHeight = new SimpleDoubleProperty(); private static Scene scene; private static Stage stage; private static VersionPage versionPage; private static Lazy gameListPage = new Lazy<>(() -> { GameListPage gameListPage = new GameListPage(); gameListPage.selectedProfileProperty().bindBidirectional(Profiles.selectedProfileProperty()); gameListPage.profilesProperty().bindContent(Profiles.profilesProperty()); FXUtils.applyDragListener(gameListPage, ModpackHelper::isFileModpackByExtension, modpacks -> { Path modpack = modpacks.get(0); Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(Profiles.getSelectedProfile(), modpack), i18n("install.modpack")); }); return gameListPage; }); private static Lazy rootPage = new Lazy<>(RootPage::new); private static DecoratorController decorator; private static DownloadPage downloadPage; private static Lazy accountListPage = new Lazy<>(() -> { AccountListPage accountListPage = new AccountListPage(); accountListPage.selectedAccountProperty().bindBidirectional(Accounts.selectedAccountProperty()); accountListPage.accountsProperty().bindContent(Accounts.getAccounts()); accountListPage.authServersProperty().bindContentBidirectional(config().getAuthlibInjectorServers()); return accountListPage; }); private static LauncherSettingsPage settingsPage; private static Lazy terracottaPage = new Lazy<>(TerracottaPage::new); private Controllers() { } public static Scene getScene() { return scene; } public static Stage getStage() { return stage; } // FXThread public static VersionPage getVersionPage() { if (versionPage == null) { versionPage = new VersionPage(); } return versionPage; } @FXThread public static void prepareVersionPage() { if (versionPage == null) { LOG.info("Prepare the version page"); versionPage = FXUtils.prepareNode(new VersionPage()); } } // FXThread public static GameListPage getGameListPage() { return gameListPage.get(); } // FXThread public static RootPage getRootPage() { return rootPage.get(); } // FXThread public static LauncherSettingsPage getSettingsPage() { if (settingsPage == null) { settingsPage = new LauncherSettingsPage(); } return settingsPage; } @FXThread public static void prepareSettingsPage() { if (settingsPage == null) { LOG.info("Prepare the settings page"); settingsPage = FXUtils.prepareNode(new LauncherSettingsPage()); } } // FXThread public static AccountListPage getAccountListPage() { return accountListPage.get(); } // FXThread public static DownloadPage getDownloadPage() { if (downloadPage == null) { downloadPage = new DownloadPage(); } return downloadPage; } @FXThread public static void prepareDownloadPage() { if (downloadPage == null) { LOG.info("Prepare the download page"); downloadPage = FXUtils.prepareNode(new DownloadPage()); } } // FXThread public static Node getTerracottaPage() { return terracottaPage.get(); } // FXThread public static DecoratorController getDecorator() { return decorator; } public static void onApplicationStop() { stageSizeChangeListener = null; if (stageX != null) { config().setX(stageX.get() / SCREEN.getBounds().getWidth()); stageX = null; } if (stageY != null) { config().setY(stageY.get() / SCREEN.getBounds().getHeight()); stageY = null; } if (stageHeight != null) { config().setHeight(stageHeight.get()); stageHeight = null; } if (stageWidth != null) { config().setWidth(stageWidth.get()); stageWidth = null; } } public static void initialize(Stage stage) { LOG.info("Start initializing application"); LOG.info("April Fools: " + AprilFools.isEnabled()); if (System.getProperty("prism.lcdtext") == null) { String fontAntiAliasing = globalConfig().getFontAntiAliasing(); if ("lcd".equalsIgnoreCase(fontAntiAliasing)) { LOG.info("Enable sub-pixel antialiasing"); System.getProperties().put("prism.lcdtext", "true"); } else if ("gray".equalsIgnoreCase(fontAntiAliasing) || OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && SCREEN.getOutputScaleX() > 1) { LOG.info("Disable sub-pixel antialiasing"); System.getProperties().put("prism.lcdtext", "false"); } } Controllers.stage = stage; stageSizeChangeListener = o -> { ReadOnlyDoubleProperty sourceProperty = (ReadOnlyDoubleProperty) o; DoubleProperty targetProperty; switch (sourceProperty.getName()) { case "x": { targetProperty = stageX; break; } case "y": { targetProperty = stageY; break; } case "width": { targetProperty = stageWidth; break; } case "height": { targetProperty = stageHeight; break; } default: { targetProperty = null; } } if (targetProperty != null && Controllers.stage != null && !Controllers.stage.isIconified() // https://github.com/HMCL-dev/HMCL/issues/4290 && (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS || !Controllers.stage.isFullScreen() && !Controllers.stage.isMaximized()) ) { targetProperty.set(sourceProperty.get()); } }; WeakInvalidationListener weakListener = new WeakInvalidationListener(stageSizeChangeListener); double initWidth = Math.max(MIN_WIDTH, config().getWidth()); double initHeight = Math.max(MIN_HEIGHT, config().getHeight()); { double initX = config().getX() * SCREEN.getBounds().getWidth(); double initY = config().getY() * SCREEN.getBounds().getHeight(); boolean invalid = true; double border = 20D; for (Screen screen : Screen.getScreens()) { Rectangle2D bound = screen.getBounds(); if (bound.getMinX() + border <= initX + initWidth && initX <= bound.getMaxX() - border && bound.getMinY() + border <= initY && initY <= bound.getMaxY() - border) { invalid = false; break; } } if (invalid) { initX = (0.5D - initWidth / SCREEN.getBounds().getWidth() / 2) * SCREEN.getBounds().getWidth(); initY = (0.5D - initHeight / SCREEN.getBounds().getHeight() / 2) * SCREEN.getBounds().getHeight(); } stage.setX(initX); stage.setY(initY); stageX.set(initX); stageY.set(initY); } stage.setHeight(initHeight); stage.setWidth(initWidth); stageHeight.set(initHeight); stageWidth.set(initWidth); stage.xProperty().addListener(weakListener); stage.yProperty().addListener(weakListener); stage.heightProperty().addListener(weakListener); stage.widthProperty().addListener(weakListener); stage.setOnCloseRequest(e -> Launcher.stopApplication()); decorator = new DecoratorController(stage, getRootPage()); if (config().getCommonDirType() == EnumCommonDirectory.CUSTOM && !FileUtils.canCreateDirectory(config().getCommonDirectory())) { config().setCommonDirType(EnumCommonDirectory.DEFAULT); dialog(i18n("launcher.cache_directory.invalid")); } Lang.thread(JavaManager::initialize, "Search Java", true); scene = new Scene(decorator.getDecorator()); scene.setFill(Color.TRANSPARENT); stage.setMinWidth(MIN_WIDTH); stage.setMinHeight(MIN_HEIGHT); decorator.getDecorator().prefWidthProperty().bind(scene.widthProperty()); decorator.getDecorator().prefHeightProperty().bind(scene.heightProperty()); StyleSheets.init(scene); FXUtils.setIcon(stage); stage.setTitle(Metadata.FULL_TITLE); stage.initStyle(StageStyle.TRANSPARENT); stage.setScene(scene); if (AnimationUtils.playWindowAnimation()) { Timeline timeline = new Timeline( new KeyFrame(Duration.millis(0), new KeyValue(decorator.getDecorator().opacityProperty(), 0, Motion.EASE), new KeyValue(decorator.getDecorator().scaleXProperty(), 0.8, Motion.EASE), new KeyValue(decorator.getDecorator().scaleYProperty(), 0.8, Motion.EASE), new KeyValue(decorator.getDecorator().scaleZProperty(), 0.8, Motion.EASE) ), new KeyFrame(Duration.millis(600), new KeyValue(decorator.getDecorator().opacityProperty(), 1, Motion.EASE), new KeyValue(decorator.getDecorator().scaleXProperty(), 1, Motion.EASE), new KeyValue(decorator.getDecorator().scaleYProperty(), 1, Motion.EASE), new KeyValue(decorator.getDecorator().scaleZProperty(), 1, Motion.EASE) ) ); timeline.play(); } if (!Architecture.SYSTEM_ARCH.isX86() && globalConfig().getPlatformPromptVersion() < 1) { Runnable continueAction = () -> globalConfig().setPlatformPromptVersion(1); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS && Architecture.SYSTEM_ARCH == Architecture.ARM64) { Controllers.dialog(i18n("fatal.unsupported_platform.macos_arm64"), null, MessageType.INFO, continueAction); } else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH == Architecture.ARM64) { Controllers.dialog(i18n("fatal.unsupported_platform.windows_arm64"), null, MessageType.INFO, continueAction); } else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && (Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64 || Architecture.SYSTEM_ARCH == Architecture.LOONGARCH64_OW || Architecture.SYSTEM_ARCH == Architecture.MIPS64EL)) { Controllers.dialog(i18n("fatal.unsupported_platform.loongarch"), null, MessageType.INFO, continueAction); } else { Controllers.dialog(i18n("fatal.unsupported_platform"), null, MessageType.WARNING, continueAction); } } if (JavaRuntime.CURRENT_VERSION < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) { Number shownTipVersion = null; try { shownTipVersion = (Number) config().getShownTips().get(JAVA_VERSION_TIP); } catch (ClassCastException e) { LOG.warning("Invalid type for shown tips key: " + JAVA_VERSION_TIP, e); } if (shownTipVersion == null || shownTipVersion.intValue() < Metadata.MINIMUM_SUPPORTED_JAVA_VERSION) { MessageDialogPane.Builder builder = new MessageDialogPane.Builder(i18n("fatal.deprecated_java_version"), null, MessageType.WARNING); String downloadLink = Metadata.getSuggestedJavaDownloadLink(); if (downloadLink != null) builder.addHyperLink( i18n("fatal.deprecated_java_version.download_link", Metadata.RECOMMENDED_JAVA_VERSION), downloadLink ); Controllers.dialog(builder .ok(() -> config().getShownTips().put(JAVA_VERSION_TIP, Metadata.MINIMUM_SUPPORTED_JAVA_VERSION)) .build()); } } // Check whether JIT is enabled in the current environment if (!JavaRuntime.CURRENT_JIT_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(JAVA_INTERPRETED_MODE_TIP))) { Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.java_interpreted_mode"), i18n("message.warning"), MessageType.WARNING) .ok(null) .addCancel(i18n("button.do_not_show_again"), () -> config().getShownTips().put(JAVA_INTERPRETED_MODE_TIP, true)) .build()); } // Check whether hardware acceleration is enabled if (!FXUtils.GPU_ACCELERATION_ENABLED && !Boolean.TRUE.equals(config().getShownTips().get(SOFTWARE_RENDERING))) { Controllers.dialog(new MessageDialogPane.Builder(i18n("warning.software_rendering"), i18n("message.warning"), MessageType.WARNING) .ok(null) .addCancel(i18n("button.do_not_show_again"), () -> config().getShownTips().put(SOFTWARE_RENDERING, true)) .build()); } if (globalConfig().getAgreementVersion() < 1) { JFXDialogLayout agreementPane = new JFXDialogLayout(); agreementPane.setHeading(new Label(i18n("launcher.agreement"))); agreementPane.setBody(new Label(i18n("launcher.agreement.hint"))); JFXHyperlink agreementLink = new JFXHyperlink(i18n("launcher.agreement")); agreementLink.setExternalLink(Metadata.EULA_URL); JFXButton yesButton = new JFXButton(i18n("launcher.agreement.accept")); yesButton.getStyleClass().add("dialog-accept"); yesButton.setOnAction(e -> { globalConfig().setAgreementVersion(1); agreementPane.fireEvent(new DialogCloseEvent()); }); JFXButton noButton = new JFXButton(i18n("launcher.agreement.decline")); noButton.getStyleClass().add("dialog-cancel"); noButton.setOnAction(e -> javafx.application.Platform.exit()); agreementPane.setActions(agreementLink, yesButton, noButton); Controllers.dialog(agreementPane); } aprilFools: if (AprilFools.isEnabled()) { int currentYear = LocalDate.now().getYear(); if (config().getShownTips().get(APRIL_FOOLS) instanceof Number year && year.intValue() >= currentYear) break aprilFools; if (!I18n.getLocale().getLocale().getLanguage().equals("zh")) break aprilFools; SupportedLocale lzh = SupportedLocale.getSupportedLocales().stream() .filter(locale -> "lzh".equals(locale.getName())) .findFirst().orElse(null); if (lzh == null) { LOG.warning("No supported locale found for lzh"); break aprilFools; } Runnable updateShowTips = () -> config().getShownTips().put(APRIL_FOOLS, currentYear); Controllers.confirmWithCountdown(i18n("launcher.april_fools.switch_lzh"), null, 10, MessageType.QUESTION, () -> { Controllers.confirm(i18n("launcher.april_fools.switch_lzh.confirm"), null, MessageType.QUESTION, () -> { LOG.info("Switching locale to " + lzh); updateShowTips.run(); config().setLocalization(lzh); Controllers.onApplicationStop(); try { FileSaver.waitForAllSaves(); } catch (InterruptedException ignored) { // Ignore } try { Restarter.restartSelf(); } catch (IOException e) { LOG.warning("Failed to restart self", e); } Platform.exit(); }, updateShowTips); }, updateShowTips); } } public static void dialog(Region content) { if (decorator != null) decorator.showDialog(content); } public static void dialog(String text) { dialog(text, null); } public static void dialog(String text, String title) { dialog(text, title, MessageType.INFO); } public static void dialog(String text, String title, MessageType type) { dialog(text, title, type, null); } public static void dialog(String text, String title, MessageType type, Runnable ok) { dialog(new MessageDialogPane.Builder(text, title, type).ok(ok).build()); } public static void confirm(String text, String title, Runnable yes, Runnable no) { confirm(text, title, MessageType.QUESTION, yes, no); } public static void confirm(String text, String title, MessageType type, Runnable yes, Runnable no) { dialog(new MessageDialogPane.Builder(text, title, type).yesOrNo(yes, no).build()); } public static void confirmAction(String text, String title, MessageType type, ButtonBase actionButton) { dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, null).build()); } public static void confirmAction(String text, String title, MessageType type, ButtonBase actionButton, Runnable cancel) { dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build()); } public static void confirmWithCountdown(String text, String title, int seconds, MessageType messageType, @Nullable Runnable ok, @Nullable Runnable cancel) { if (seconds <= 0) throw new IllegalArgumentException("Seconds must be greater than 0"); JFXButton btnOk = new JFXButton(i18n("button.ok")); btnOk.getStyleClass().add(messageType == MessageType.WARNING || messageType == MessageType.ERROR ? "dialog-error" : "dialog-accept"); if (ok != null) btnOk.setOnAction(e -> ok.run()); btnOk.setDisable(true); KeyFrame[] keyFrames = new KeyFrame[seconds + 1]; for (int i = 0; i < seconds; i++) { keyFrames[i] = new KeyFrame(Duration.seconds(i), new KeyValue(btnOk.textProperty(), i18n("button.ok.countdown", seconds - i))); } keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds), new KeyValue(btnOk.textProperty(), i18n("button.ok")), new KeyValue(btnOk.disableProperty(), false)); Timeline timeline = new Timeline(keyFrames); confirmAction(text, title, messageType, btnOk, () -> { timeline.stop(); if (cancel != null) cancel.run(); }); timeline.play(); } public static CompletableFuture prompt(String title, FutureCallback onResult) { return prompt(title, onResult, ""); } public static CompletableFuture prompt(String title, FutureCallback onResult, String initialValue, ValidatorBase... validators) { InputDialogPane pane = new InputDialogPane(title, initialValue, onResult, validators); dialog(pane); return pane.getCompletableFuture(); } public static CompletableFuture>> prompt(PromptDialogPane.Builder builder) { PromptDialogPane pane = new PromptDialogPane(builder); dialog(pane); return pane.getCompletableFuture(); } public static TaskExecutorDialogPane taskDialog(TaskExecutor executor, String title, TaskCancellationAction onCancel) { TaskExecutorDialogPane pane = new TaskExecutorDialogPane(onCancel); pane.setTitle(title); pane.setExecutor(executor); dialog(pane); return pane; } public static TaskExecutorDialogPane taskDialog(Task task, String title, TaskCancellationAction onCancel) { TaskExecutor executor = task.executor(); TaskExecutorDialogPane pane = taskDialog(executor, title, onCancel); executor.start(); return pane; } public static void navigate(Node node) { decorator.navigate(node, ContainerAnimations.NAVIGATION, Motion.SHORT4, Motion.EASE); } public static void navigateForward(Node node) { decorator.navigate(node, ContainerAnimations.FORWARD, Motion.SHORT4, Motion.EASE); } public static void showToast(String content) { decorator.showToast(content); } public static void onHyperlinkAction(String href) { if (href.startsWith("hmcl://")) { switch (href) { case "hmcl://settings/feedback": Controllers.getSettingsPage().showFeedback(); Controllers.navigate(Controllers.getSettingsPage()); break; case "hmcl://game/launch": Profile profile = Profiles.getSelectedProfile(); Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep); break; } } else { FXUtils.openLink(href); } } public static boolean isStopped() { return decorator == null; } public static void shutdown() { rootPage = null; versionPage = null; gameListPage = null; downloadPage = null; accountListPage = null; settingsPage = null; terracottaPage = null; decorator = null; stage = null; scene = null; onApplicationStop(); FXUtils.shutdown(); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.control.TextArea; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.stage.Stage; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.countly.CrashReport; import org.jackhuang.hmcl.upgrade.UpdateChecker; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /** * @author huangyuhui */ public class CrashWindow extends Stage { public CrashWindow(CrashReport report) { Label lblCrash = new Label(); if (report.getThrowable() instanceof InternalError) lblCrash.setText(i18n("launcher.crash.java_internal_error")); else if (UpdateChecker.isOutdated()) lblCrash.setText(i18n("launcher.crash.hmcl_out_dated")); else lblCrash.setText(i18n("launcher.crash")); lblCrash.setWrapText(true); TextArea textArea = new TextArea(); textArea.setText(report.getDisplayText()); textArea.setEditable(false); Button btnContact = new Button(); btnContact.setText(i18n("launcher.contact")); btnContact.setOnAction(event -> FXUtils.openLink(Metadata.CONTACT_URL)); HBox box = new HBox(); box.setStyle("-fx-padding: 8px;"); box.getChildren().add(btnContact); box.setAlignment(Pos.CENTER_RIGHT); BorderPane pane = new BorderPane(); StackPane stackPane = new StackPane(); stackPane.setStyle("-fx-padding: 8px;"); stackPane.getChildren().add(lblCrash); pane.setTop(stackPane); pane.setCenter(textArea); pane.setBottom(box); Scene scene = new Scene(pane, 800, 480); setScene(scene); FXUtils.setIcon(this); setTitle(i18n("message.error")); setOnCloseRequest(e -> javafx.application.Platform.exit()); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogController.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import org.jackhuang.hmcl.auth.*; import org.jackhuang.hmcl.ui.account.ClassicAccountLoginDialog; import org.jackhuang.hmcl.ui.account.MicrosoftAccountLoginPane; import java.util.Optional; import java.util.concurrent.CancellationException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; public final class DialogController { private DialogController() { } public static AuthInfo logIn(Account account) throws CancellationException, AuthenticationException, InterruptedException { if (account instanceof ClassicAccount) { CountDownLatch latch = new CountDownLatch(1); AtomicReference res = new AtomicReference<>(null); runInFX(() -> { ClassicAccountLoginDialog pane = new ClassicAccountLoginDialog((ClassicAccount) account, it -> { res.set(it); latch.countDown(); }, latch::countDown); Controllers.dialog(pane); }); latch.await(); return Optional.ofNullable(res.get()).orElseThrow(CancellationException::new); } else if (account instanceof OAuthAccount) { CountDownLatch latch = new CountDownLatch(1); AtomicReference res = new AtomicReference<>(null); runInFX(() -> { MicrosoftAccountLoginPane pane = new MicrosoftAccountLoginPane(account, it -> { res.set(it); latch.countDown(); }, latch::countDown, false); Controllers.dialog(pane); }); latch.await(); return Optional.ofNullable(res.get()).orElseThrow(CancellationException::new); } return account.logIn(); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/DialogUtils.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXDialog; import javafx.application.Platform; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.event.EventHandler; import javafx.scene.Node; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.construct.DialogAware; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; import org.jackhuang.hmcl.ui.construct.JFXDialogPane; import org.jackhuang.hmcl.ui.decorator.Decorator; import org.jetbrains.annotations.Nullable; import java.util.Optional; import java.util.function.Consumer; public final class DialogUtils { private DialogUtils() { } public static final String PROPERTY_DIALOG_INSTANCE = DialogUtils.class.getName() + ".dialog.instance"; public static final String PROPERTY_DIALOG_PANE_INSTANCE = DialogUtils.class.getName() + ".dialog.pane.instance"; public static final String PROPERTY_DIALOG_CLOSE_HANDLER = DialogUtils.class.getName() + ".dialog.closeListener"; public static final String PROPERTY_PARENT_PANE_REF = DialogUtils.class.getName() + ".dialog.parentPaneRef"; public static final String PROPERTY_PARENT_DIALOG_REF = DialogUtils.class.getName() + ".dialog.parentDialogRef"; public static void show(Decorator decorator, Node content) { if (decorator.getDrawerWrapper() == null) { Platform.runLater(() -> show(decorator, content)); return; } show(decorator.getDrawerWrapper(), content, (dialog) -> { JFXDialogPane pane = (JFXDialogPane) dialog.getContent(); decorator.capableDraggingWindow(dialog); decorator.forbidDraggingWindow(pane); dialog.setDialogContainer(decorator.getDrawerWrapper()); }); } public static void show(StackPane container, Node content) { show(container, content, null); } public static void show(StackPane container, Node content, @Nullable Consumer onDialogCreated) { FXUtils.checkFxUserThread(); JFXDialog dialog = (JFXDialog) container.getProperties().get(PROPERTY_DIALOG_INSTANCE); JFXDialogPane dialogPane = (JFXDialogPane) container.getProperties().get(PROPERTY_DIALOG_PANE_INSTANCE); if (dialog == null) { dialog = new JFXDialog(AnimationUtils.isAnimationEnabled() ? JFXDialog.DialogTransition.CENTER : JFXDialog.DialogTransition.NONE); dialogPane = new JFXDialogPane(); dialog.setContent(dialogPane); dialog.setDialogContainer(container); dialog.setOverlayClose(false); container.getProperties().put(PROPERTY_DIALOG_INSTANCE, dialog); container.getProperties().put(PROPERTY_DIALOG_PANE_INSTANCE, dialogPane); if (onDialogCreated != null) { onDialogCreated.accept(dialog); } dialog.show(); } content.getProperties().put(PROPERTY_PARENT_PANE_REF, dialogPane); content.getProperties().put(PROPERTY_PARENT_DIALOG_REF, dialog); dialogPane.push(content); EventHandler handler = event -> close(content); content.getProperties().put(PROPERTY_DIALOG_CLOSE_HANDLER, handler); content.addEventHandler(DialogCloseEvent.CLOSE, handler); handleDialogShown(dialog, content); } private static void handleDialogShown(JFXDialog dialog, Node node) { if (dialog.isVisible()) { dialog.requestFocus(); if (node instanceof DialogAware dialogAware) dialogAware.onDialogShown(); } else { dialog.visibleProperty().addListener(new ChangeListener<>() { @Override public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { if (newValue) { dialog.requestFocus(); if (node instanceof DialogAware dialogAware) dialogAware.onDialogShown(); observable.removeListener(this); } } }); } } @SuppressWarnings("unchecked") public static void close(Node content) { FXUtils.checkFxUserThread(); Optional.ofNullable(content.getProperties().get(PROPERTY_DIALOG_CLOSE_HANDLER)) .ifPresent(handler -> content.removeEventHandler(DialogCloseEvent.CLOSE, (EventHandler) handler)); JFXDialogPane pane = (JFXDialogPane) content.getProperties().get(PROPERTY_PARENT_PANE_REF); JFXDialog dialog = (JFXDialog) content.getProperties().get(PROPERTY_PARENT_DIALOG_REF); if (dialog != null && pane != null) { if (pane.size() == 1 && pane.peek().orElse(null) == content) { dialog.setOnDialogClosed(e -> pane.pop(content)); dialog.close(); StackPane container = dialog.getDialogContainer(); if (container != null) { container.getProperties().remove(PROPERTY_DIALOG_INSTANCE); container.getProperties().remove(PROPERTY_DIALOG_PANE_INSTANCE); container.getProperties().remove(PROPERTY_PARENT_DIALOG_REF); container.getProperties().remove(PROPERTY_PARENT_PANE_REF); } } else { pane.pop(content); } if (content instanceof DialogAware dialogAware) { dialogAware.onDialogClosed(); } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.*; import javafx.animation.Animation; import javafx.animation.Interpolator; import javafx.animation.PauseTransition; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.Observable; import javafx.beans.WeakInvalidationListener; import javafx.beans.WeakListener; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableBooleanValue; import javafx.beans.value.ObservableValue; import javafx.beans.value.WeakChangeListener; import javafx.collections.ObservableMap; import javafx.event.Event; import javafx.event.EventDispatcher; import javafx.event.EventHandler; import javafx.event.EventType; import javafx.geometry.Bounds; import javafx.geometry.Rectangle2D; import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.control.skin.VirtualFlow; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.*; import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.scene.shape.Rectangle; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; import javafx.stage.FileChooser; import javafx.stage.Screen; import javafx.stage.Stage; import javafx.util.Callback; import javafx.util.Duration; import javafx.util.StringConverter; import org.jackhuang.hmcl.setting.StyleSheets; import org.jackhuang.hmcl.task.CacheFileTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.Motion; import org.jackhuang.hmcl.ui.construct.IconedMenuItem; import org.jackhuang.hmcl.ui.construct.MenuSeparator; import org.jackhuang.hmcl.ui.construct.PopupMenu; import org.jackhuang.hmcl.ui.image.ImageLoader; import org.jackhuang.hmcl.ui.image.ImageUtils; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.ResourceNotFoundError; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.javafx.ExtendedProperties; import org.jackhuang.hmcl.util.javafx.SafeStringConverter; import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.SystemUtils; import org.jetbrains.annotations.Nullable; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.parsers.ParserConfigurationException; import java.io.BufferedInputStream; import java.io.File; import java.io.IOException; import java.io.StringReader; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.ref.WeakReference; import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; import java.net.URLConnection; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.function.*; import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.Lang.tryCast; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class FXUtils { private FXUtils() { } public static final int JAVAFX_MAJOR_VERSION; public static final String GRAPHICS_PIPELINE; public static final boolean GPU_ACCELERATION_ENABLED; static { String pipelineName = ""; try { Object pipeline = Class.forName("com.sun.prism.GraphicsPipeline").getMethod("getPipeline").invoke(null); if (pipeline != null) { pipelineName = pipeline.getClass().getName(); } } catch (Throwable e) { LOG.warning("Failed to get prism pipeline", e); } GRAPHICS_PIPELINE = pipelineName; GPU_ACCELERATION_ENABLED = !pipelineName.endsWith(".SWPipeline"); } /// @see Platform.Preferences public static final @Nullable ObservableMap PREFERENCES; public static final @Nullable ObservableBooleanValue DARK_MODE; public static final @Nullable Boolean REDUCED_MOTION; public static final @Nullable ReadOnlyObjectProperty ACCENT_COLOR; public static final @Nullable MethodHandle TEXT_TRUNCATED_PROPERTY; public static final @Nullable MethodHandle FOCUS_VISIBLE_PROPERTY; static { String jfxVersion = System.getProperty("javafx.version"); int majorVersion = -1; if (jfxVersion != null) { Matcher matcher = Pattern.compile("^(?[0-9]+)").matcher(jfxVersion); if (matcher.find()) { majorVersion = Lang.parseInt(matcher.group(), -1); } } JAVAFX_MAJOR_VERSION = majorVersion; ObservableMap preferences = null; ObservableBooleanValue darkMode = null; ReadOnlyObjectProperty accentColorProperty = null; Boolean reducedMotion = null; if (JAVAFX_MAJOR_VERSION >= 22) { try { MethodHandles.Lookup lookup = MethodHandles.publicLookup(); Class preferencesClass = Class.forName("javafx.application.Platform$Preferences"); @SuppressWarnings("unchecked") var preferences0 = (ObservableMap) lookup.findStatic(Platform.class, "getPreferences", MethodType.methodType(preferencesClass)) .invoke(); preferences = preferences0; @SuppressWarnings("unchecked") var colorSchemeProperty = (ReadOnlyObjectProperty>) lookup.findVirtual(preferencesClass, "colorSchemeProperty", MethodType.methodType(ReadOnlyObjectProperty.class)) .invoke(preferences); darkMode = Bindings.createBooleanBinding(() -> "DARK".equals(colorSchemeProperty.get().name()), colorSchemeProperty); @SuppressWarnings("unchecked") var accentColorProperty0 = (ReadOnlyObjectProperty) lookup.findVirtual(preferencesClass, "accentColorProperty", MethodType.methodType(ReadOnlyObjectProperty.class)) .invoke(preferences); accentColorProperty = accentColorProperty0; if (JAVAFX_MAJOR_VERSION >= 24) { reducedMotion = (boolean) lookup.findVirtual(preferencesClass, "isReducedMotion", MethodType.methodType(boolean.class)) .invoke(preferences); } } catch (Throwable e) { LOG.warning("Failed to get preferences", e); } } PREFERENCES = preferences; DARK_MODE = darkMode; REDUCED_MOTION = reducedMotion; ACCENT_COLOR = accentColorProperty; MethodHandle textTruncatedProperty = null; if (JAVAFX_MAJOR_VERSION >= 23) { try { textTruncatedProperty = MethodHandles.publicLookup().findVirtual( Labeled.class, "textTruncatedProperty", MethodType.methodType(ReadOnlyBooleanProperty.class) ); } catch (Throwable e) { LOG.warning("Failed to lookup textTruncatedProperty", e); } } TEXT_TRUNCATED_PROPERTY = textTruncatedProperty; MethodHandle focusVisibleProperty = null; if (JAVAFX_MAJOR_VERSION >= 19) { try { focusVisibleProperty = MethodHandles.publicLookup().findVirtual( Node.class, "focusVisibleProperty", MethodType.methodType(ReadOnlyBooleanProperty.class) ); } catch (Throwable e) { LOG.warning("Failed to lookup focusVisibleProperty", e); } } FOCUS_VISIBLE_PROPERTY = focusVisibleProperty; } public static final String DEFAULT_MONOSPACE_FONT = OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "Consolas" : "Monospace"; public static final List IMAGE_EXTENSIONS = Lang.immutableListOf( "png", "jpg", "jpeg", "bmp", "gif", "webp", "apng" ); private static final Map builtinImageCache = new ConcurrentHashMap<>(); public static void shutdown() { builtinImageCache.clear(); } public static void runInFX(Runnable runnable) { if (Platform.isFxApplicationThread()) { runnable.run(); } else { Platform.runLater(runnable); } } public static void checkFxUserThread() { if (!Platform.isFxApplicationThread()) { throw new IllegalStateException("Not on FX application thread; currentThread = " + Thread.currentThread().getName()); } } public static InvalidationListener onInvalidating(Runnable action) { return arg -> action.run(); } public static void onChange(ObservableValue value, Consumer consumer) { value.addListener((a, b, c) -> consumer.accept(c)); } public static ChangeListener onWeakChange(ObservableValue value, Consumer consumer) { ChangeListener listener = (a, b, c) -> consumer.accept(c); value.addListener(new WeakChangeListener<>(listener)); return listener; } public static void onChangeAndOperate(ObservableValue value, Consumer consumer) { consumer.accept(value.getValue()); onChange(value, consumer); } public static ChangeListener onWeakChangeAndOperate(ObservableValue value, Consumer consumer) { consumer.accept(value.getValue()); return onWeakChange(value, consumer); } public static InvalidationListener observeWeak(Runnable runnable, Observable... observables) { InvalidationListener originalListener = observable -> runnable.run(); WeakInvalidationListener listener = new WeakInvalidationListener(originalListener); for (Observable observable : observables) { observable.addListener(listener); } runnable.run(); return originalListener; } public static void runLaterIf(BooleanSupplier condition, Runnable runnable) { if (condition.getAsBoolean()) Platform.runLater(() -> runLaterIf(condition, runnable)); else runnable.run(); } public static void limitSize(ImageView imageView, double maxWidth, double maxHeight) { imageView.setPreserveRatio(true); onChangeAndOperate(imageView.imageProperty(), image -> { if (image != null && (image.getWidth() > maxWidth || image.getHeight() > maxHeight)) { imageView.setFitHeight(maxHeight); imageView.setFitWidth(maxWidth); } else { imageView.setFitHeight(-1); imageView.setFitWidth(-1); } }); } private static class ListenerPair { private final ObservableValue value; private final ChangeListener listener; ListenerPair(ObservableValue value, ChangeListener listener) { this.value = value; this.listener = listener; } void bind() { value.addListener(listener); } void unbind() { value.removeListener(listener); } } public static void addListener(Node node, String key, ObservableValue value, Consumer callback) { ListenerPair pair = new ListenerPair<>(value, (a, b, newValue) -> callback.accept(newValue)); node.getProperties().put(key, pair); pair.bind(); } public static void removeListener(Node node, String key) { tryCast(node.getProperties().get(key), ListenerPair.class) .ifPresent(info -> { info.unbind(); node.getProperties().remove(key); }); } @SuppressWarnings("unchecked") public static void ignoreEvent(Node node, EventType type, Predicate filter) { EventDispatcher oldDispatcher = node.getEventDispatcher(); node.setEventDispatcher((event, tail) -> { EventType t = event.getEventType(); while (t != null && t != type) t = t.getSuperType(); if (t == type && filter.test((T) event)) { return tail.dispatchEvent(event); } else { return oldDispatcher.dispatchEvent(event, tail); } }); } public static void setValidateWhileTextChanged(Node field, boolean validate) { if (field instanceof JFXTextField) { if (validate) { addListener(field, "FXUtils.validation", ((JFXTextField) field).textProperty(), o -> ((JFXTextField) field).validate()); } else { removeListener(field, "FXUtils.validation"); } ((JFXTextField) field).validate(); } else if (field instanceof JFXPasswordField) { if (validate) { addListener(field, "FXUtils.validation", ((JFXPasswordField) field).textProperty(), o -> ((JFXPasswordField) field).validate()); } else { removeListener(field, "FXUtils.validation"); } ((JFXPasswordField) field).validate(); } else throw new IllegalArgumentException("Only JFXTextField and JFXPasswordField allowed"); } public static boolean getValidateWhileTextChanged(Node field) { return field.getProperties().containsKey("FXUtils.validation"); } public static Rectangle setOverflowHidden(Region region) { Rectangle rectangle = new Rectangle(); rectangle.widthProperty().bind(region.widthProperty()); rectangle.heightProperty().bind(region.heightProperty()); region.setClip(rectangle); return rectangle; } public static Rectangle setOverflowHidden(Region region, double arc) { Rectangle rectangle = setOverflowHidden(region); rectangle.setArcWidth(arc); rectangle.setArcHeight(arc); return rectangle; } public static void setLimitWidth(Region region, double width) { region.setMaxWidth(width); region.setMinWidth(width); region.setPrefWidth(width); } public static double getLimitWidth(Region region) { return region.getMaxWidth(); } public static void setLimitHeight(Region region, double height) { region.setMaxHeight(height); region.setMinHeight(height); region.setPrefHeight(height); } public static double getLimitHeight(Region region) { return region.getMaxHeight(); } public static void limitCellWidth(ListView listView, ListCell cell) { ReadOnlyDoubleProperty widthProperty; if (listView.lookup(".clipped-container") instanceof Region clippedContainer) { widthProperty = clippedContainer.widthProperty(); } else { widthProperty = listView.widthProperty(); } cell.maxWidthProperty().bind(widthProperty); cell.prefWidthProperty().bind(widthProperty); cell.minWidthProperty().bind(widthProperty); } public static void smoothScrolling(ScrollPane scrollPane) { if (AnimationUtils.isAnimationEnabled()) ScrollUtils.addSmoothScrolling(scrollPane); } public static void smoothScrolling(VirtualFlow virtualFlow) { if (AnimationUtils.isAnimationEnabled()) ScrollUtils.addSmoothScrolling(virtualFlow); } /// If the current environment is JavaFX 23 or higher, this method returns [Labeled#textTruncatedProperty()]; /// Otherwise, it returns `null`. public static @Nullable ReadOnlyBooleanProperty textTruncatedProperty(Labeled labeled) { if (TEXT_TRUNCATED_PROPERTY != null) { try { return (ReadOnlyBooleanProperty) TEXT_TRUNCATED_PROPERTY.invokeExact(labeled); } catch (RuntimeException | Error e) { throw e; } catch (Throwable e) { throw new RuntimeException(e); } } else { return null; } } public static @Nullable ReadOnlyBooleanProperty focusVisibleProperty(Node node) { if (FOCUS_VISIBLE_PROPERTY != null) { try { return (ReadOnlyBooleanProperty) FOCUS_VISIBLE_PROPERTY.invokeExact(node); } catch (RuntimeException | Error e) { throw e; } catch (Throwable e) { throw new RuntimeException(e); } } else { return null; } } private static final Duration TOOLTIP_FAST_SHOW_DELAY = Duration.millis(50); private static final Duration TOOLTIP_SLOW_SHOW_DELAY = Duration.millis(500); private static final Duration TOOLTIP_SHOW_DURATION = Duration.millis(5000); public static void installTooltip(Node node, Duration showDelay, Duration showDuration, Duration hideDelay, Tooltip tooltip) { tooltip.setShowDelay(showDelay); tooltip.setShowDuration(showDuration); tooltip.setHideDelay(hideDelay); Tooltip.install(node, tooltip); } public static void installFastTooltip(Node node, Tooltip tooltip) { runInFX(() -> installTooltip(node, TOOLTIP_FAST_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip)); } public static void installFastTooltip(Node node, String tooltip) { installFastTooltip(node, new Tooltip(tooltip)); } public static void installSlowTooltip(Node node, Tooltip tooltip) { runInFX(() -> installTooltip(node, TOOLTIP_SLOW_SHOW_DELAY, TOOLTIP_SHOW_DURATION, Duration.ZERO, tooltip)); } public static void installSlowTooltip(Node node, String tooltip) { installSlowTooltip(node, new Tooltip(tooltip)); } public static void playAnimation(Node node, String animationKey, Animation animation) { animationKey = "hmcl.animations." + animationKey; if (node.getProperties().get(animationKey) instanceof Animation oldAnimation) oldAnimation.stop(); animation.play(); node.getProperties().put(animationKey, animation); } public static void openFolder(Path file) { if (file.getFileSystem() != FileSystems.getDefault()) { LOG.warning("Cannot open folder as the file system is not supported: " + file); return; } try { Files.createDirectories(file); } catch (IOException e) { LOG.warning("Failed to create directory " + file); return; } String path = FileUtils.getAbsolutePath(file); String openCommand; if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) openCommand = "explorer.exe"; else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) openCommand = "/usr/bin/open"; else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && Files.exists(Path.of("/usr/bin/xdg-open"))) openCommand = "/usr/bin/xdg-open"; else openCommand = null; thread(() -> { if (openCommand != null) { try { int exitCode = SystemUtils.callExternalProcess(openCommand, path); // explorer.exe always return 1 if (exitCode == 0 || (exitCode == 1 && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)) return; else LOG.warning("Open " + path + " failed with code " + exitCode); } catch (Throwable e) { LOG.warning("Unable to open " + path + " by executing " + openCommand, e); } } // Fallback to java.awt.Desktop::open try { java.awt.Desktop.getDesktop().open(file.toFile()); } catch (Throwable e) { LOG.error("Unable to open " + path + " by java.awt.Desktop.getDesktop()::open", e); } }); } public static void showFileInExplorer(Path file) { String path = file.toAbsolutePath().toString(); String[] openCommands; if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) openCommands = new String[]{"explorer.exe", "/select,", path}; else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) openCommands = new String[]{"/usr/bin/open", "-R", path}; else if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && SystemUtils.which("dbus-send") != null) openCommands = new String[]{ "dbus-send", "--print-reply", "--dest=org.freedesktop.FileManager1", "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems", "array:string:" + file.toAbsolutePath().toUri(), "string:" }; else openCommands = null; if (openCommands != null) { thread(() -> { try { int exitCode = SystemUtils.callExternalProcess(openCommands); // explorer.exe always return 1 if (exitCode == 0 || (exitCode == 1 && OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)) return; else LOG.warning("Show " + path + " in explorer failed with code " + exitCode); } catch (Throwable e) { LOG.warning("Unable to show " + path + " in explorer", e); } // Fallback to open folder openFolder(file.getParent()); }); } else { // We do not have a universal method to show file in file manager. openFolder(file.getParent()); } } private static final String[] linuxBrowsers = { "xdg-open", "google-chrome", "firefox", "microsoft-edge", "opera", "konqueror", "mozilla" }; /** * Open URL in browser * * @param link null is allowed but will be ignored */ public static void openLink(String link) { if (link == null) return; String uri = NetworkUtils.encodeLocation(link); thread(() -> { try { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { Runtime.getRuntime().exec(new String[]{"rundll32.exe", "url.dll,FileProtocolHandler", uri}); return; } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { Runtime.getRuntime().exec(new String[]{"open", uri}); return; } else { for (String browser : linuxBrowsers) { Path path = SystemUtils.which(browser); if (path != null) { try { Runtime.getRuntime().exec(new String[]{path.toString(), uri}); return; } catch (Throwable ignored) { } } } LOG.warning("No known browser found"); } } catch (Throwable e) { LOG.warning("Failed to open link: " + link + ", fallback to java.awt.Desktop", e); } try { java.awt.Desktop.getDesktop().browse(new URI(uri)); } catch (Throwable e) { LOG.warning("Failed to open link: " + link, e); } }); } public static void bind(JFXTextField textField, Property property, StringConverter converter) { TextFieldBinding binding = new TextFieldBinding<>(textField, property, converter); binding.updateTextField(); textField.getProperties().put("FXUtils.bind.binding", binding); textField.focusedProperty().addListener(binding.focusedListener); textField.sceneProperty().addListener(binding.sceneListener); property.addListener(binding.propertyListener); } public static void bindInt(JFXTextField textField, Property property) { bind(textField, property, SafeStringConverter.fromInteger()); } public static void bindString(JFXTextField textField, Property property) { bind(textField, property, null); } public static void unbind(JFXTextField textField, Property property) { TextFieldBinding binding = (TextFieldBinding) textField.getProperties().remove("FXUtils.bind.binding"); if (binding != null) { textField.focusedProperty().removeListener(binding.focusedListener); textField.sceneProperty().removeListener(binding.sceneListener); property.removeListener(binding.propertyListener); } } private static final class TextFieldBinding { private final JFXTextField textField; private final Property property; private final StringConverter converter; public final ChangeListener focusedListener; public final ChangeListener sceneListener; public final InvalidationListener propertyListener; public TextFieldBinding(JFXTextField textField, Property property, StringConverter converter) { this.textField = textField; this.property = property; this.converter = converter; focusedListener = (observable, oldFocused, newFocused) -> { if (oldFocused && !newFocused) { if (textField.validate()) { updateProperty(); } else { // Rollback to old value updateTextField(); } } }; sceneListener = (observable, oldScene, newScene) -> { if (oldScene != null && newScene == null) { // Component is being removed from scene if (textField.validate()) { updateProperty(); } } }; propertyListener = observable -> { updateTextField(); }; } public void updateProperty() { String newText = textField.getText(); @SuppressWarnings("unchecked") T newValue = converter == null ? (T) newText : converter.fromString(newText); if (!Objects.equals(newValue, property.getValue())) { property.setValue(newValue); } } public void updateTextField() { T value = property.getValue(); textField.setText(converter == null ? (String) value : converter.toString(value)); } } private static final class EnumBidirectionalBinding> implements InvalidationListener, WeakListener { private final WeakReference> comboBoxRef; private final WeakReference> propertyRef; private final int hashCode; private boolean updating = false; private EnumBidirectionalBinding(JFXComboBox comboBox, Property property) { this.comboBoxRef = new WeakReference<>(comboBox); this.propertyRef = new WeakReference<>(property); this.hashCode = System.identityHashCode(comboBox) ^ System.identityHashCode(property); } @Override public void invalidated(Observable sourceProperty) { if (!updating) { final JFXComboBox comboBox = comboBoxRef.get(); final Property property = propertyRef.get(); if (comboBox == null || property == null) { if (comboBox != null) { comboBox.getSelectionModel().selectedItemProperty().removeListener(this); } if (property != null) { property.removeListener(this); } } else { updating = true; try { if (property == sourceProperty) { E newValue = property.getValue(); comboBox.getSelectionModel().select(newValue); } else { E newValue = comboBox.getSelectionModel().getSelectedItem(); property.setValue(newValue); } } finally { updating = false; } } } } @Override public boolean wasGarbageCollected() { return comboBoxRef.get() == null || propertyRef.get() == null; } @Override public int hashCode() { return hashCode; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof EnumBidirectionalBinding)) return false; EnumBidirectionalBinding that = (EnumBidirectionalBinding) o; final JFXComboBox comboBox = this.comboBoxRef.get(); final Property property = this.propertyRef.get(); final JFXComboBox thatComboBox = that.comboBoxRef.get(); final Property thatProperty = that.propertyRef.get(); if (comboBox == null || property == null || thatComboBox == null || thatProperty == null) return false; return comboBox == thatComboBox && property == thatProperty; } } /** * Bind combo box selection with given enum property bidirectionally. * You should only and always use {@code bindEnum} as well as {@code unbindEnum} at the same time. * * @param comboBox the combo box being bound with {@code property}. * @param property the property being bound with {@code combo box}. * @see #unbindEnum(JFXComboBox, Property) * @see ExtendedProperties#selectedItemPropertyFor(ComboBox) */ public static > void bindEnum(JFXComboBox comboBox, Property property) { EnumBidirectionalBinding binding = new EnumBidirectionalBinding<>(comboBox, property); comboBox.getSelectionModel().selectedItemProperty().removeListener(binding); property.removeListener(binding); comboBox.getSelectionModel().select(property.getValue()); comboBox.getSelectionModel().selectedItemProperty().addListener(binding); property.addListener(binding); } /** * Unbind combo box selection with given enum property bidirectionally. * You should only and always use {@code bindEnum} as well as {@code unbindEnum} at the same time. * * @param comboBox the combo box being bound with the property which can be inferred by {@code bindEnum}. * @see #bindEnum(JFXComboBox, Property) * @see ExtendedProperties#selectedItemPropertyFor(ComboBox) */ public static > void unbindEnum(JFXComboBox comboBox, Property property) { EnumBidirectionalBinding binding = new EnumBidirectionalBinding<>(comboBox, property); comboBox.getSelectionModel().selectedItemProperty().removeListener(binding); property.removeListener(binding); } private static final class PaintBidirectionalBinding implements InvalidationListener, WeakListener { private final WeakReference colorPickerRef; private final WeakReference> propertyRef; private final int hashCode; private boolean updating = false; private PaintBidirectionalBinding(ColorPicker colorPicker, Property property) { this.colorPickerRef = new WeakReference<>(colorPicker); this.propertyRef = new WeakReference<>(property); this.hashCode = System.identityHashCode(colorPicker) ^ System.identityHashCode(property); } @Override public void invalidated(Observable sourceProperty) { if (!updating) { final ColorPicker colorPicker = colorPickerRef.get(); final Property property = propertyRef.get(); if (colorPicker == null || property == null) { if (colorPicker != null) { colorPicker.valueProperty().removeListener(this); } if (property != null) { property.removeListener(this); } } else { updating = true; try { if (property == sourceProperty) { Paint newValue = property.getValue(); if (newValue instanceof Color) colorPicker.setValue((Color) newValue); else colorPicker.setValue(null); } else { Paint newValue = colorPicker.getValue(); property.setValue(newValue); } } finally { updating = false; } } } } @Override public boolean wasGarbageCollected() { return colorPickerRef.get() == null || propertyRef.get() == null; } @Override public int hashCode() { return hashCode; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof FXUtils.PaintBidirectionalBinding)) return false; var that = (FXUtils.PaintBidirectionalBinding) o; final ColorPicker colorPicker = this.colorPickerRef.get(); final Property property = this.propertyRef.get(); final ColorPicker thatColorPicker = that.colorPickerRef.get(); final Property thatProperty = that.propertyRef.get(); if (colorPicker == null || property == null || thatColorPicker == null || thatProperty == null) return false; return colorPicker == thatColorPicker && property == thatProperty; } } public static void bindPaint(ColorPicker colorPicker, Property property) { PaintBidirectionalBinding binding = new PaintBidirectionalBinding(colorPicker, property); colorPicker.valueProperty().removeListener(binding); property.removeListener(binding); if (property.getValue() instanceof Color) colorPicker.setValue((Color) property.getValue()); else colorPicker.setValue(null); colorPicker.valueProperty().addListener(binding); property.addListener(binding); } private static final class WindowsSizeBidirectionalBinding implements InvalidationListener, WeakListener { private final WeakReference> comboBoxRef; private final WeakReference widthPropertyRef; private final WeakReference heightPropertyRef; private final int hashCode; private boolean updating = false; private WindowsSizeBidirectionalBinding(JFXComboBox comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) { this.comboBoxRef = new WeakReference<>(comboBox); this.widthPropertyRef = new WeakReference<>(widthProperty); this.heightPropertyRef = new WeakReference<>(heightProperty); this.hashCode = System.identityHashCode(comboBox) ^ System.identityHashCode(widthProperty) ^ System.identityHashCode(heightProperty); } @Override public void invalidated(Observable observable) { if (!updating) { var comboBox = this.comboBoxRef.get(); var widthProperty = this.widthPropertyRef.get(); var heightProperty = this.heightPropertyRef.get(); if (comboBox == null || widthProperty == null || heightProperty == null) { if (comboBox != null) { comboBox.focusedProperty().removeListener(this); comboBox.sceneProperty().removeListener(this); } if (widthProperty != null) widthProperty.removeListener(this); if (heightProperty != null) heightProperty.removeListener(this); } else { updating = true; try { int width = widthProperty.get(); int height = heightProperty.get(); if (observable instanceof ReadOnlyProperty && ((ReadOnlyProperty) observable).getBean() == comboBox) { String value = comboBox.valueProperty().get(); if (value == null) value = ""; int idx = value.indexOf('x'); if (idx < 0) idx = value.indexOf('*'); if (idx < 0) { LOG.warning("Bad window size: " + value); comboBox.setValue(width + "x" + height); return; } String widthStr = value.substring(0, idx).trim(); String heightStr = value.substring(idx + 1).trim(); int newWidth; int newHeight; try { newWidth = Integer.parseInt(widthStr); newHeight = Integer.parseInt(heightStr); } catch (NumberFormatException e) { LOG.warning("Bad window size: " + value); comboBox.setValue(width + "x" + height); return; } widthProperty.set(newWidth); heightProperty.set(newHeight); } else { comboBox.setValue(width + "x" + height); } } finally { updating = false; } } } } @Override public boolean wasGarbageCollected() { return this.comboBoxRef.get() == null || this.widthPropertyRef.get() == null || this.heightPropertyRef.get() == null; } @Override public int hashCode() { return hashCode; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof WindowsSizeBidirectionalBinding)) return false; var that = (WindowsSizeBidirectionalBinding) obj; var comboBox = this.comboBoxRef.get(); var widthProperty = this.widthPropertyRef.get(); var heightProperty = this.heightPropertyRef.get(); var thatComboBox = that.comboBoxRef.get(); var thatWidthProperty = that.widthPropertyRef.get(); var thatHeightProperty = that.heightPropertyRef.get(); if (comboBox == null || widthProperty == null || heightProperty == null || thatComboBox == null || thatWidthProperty == null || thatHeightProperty == null) { return false; } return comboBox == thatComboBox && widthProperty == thatWidthProperty && heightProperty == thatHeightProperty; } } public static void bindWindowsSize(JFXComboBox comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) { comboBox.setValue(widthProperty.get() + "x" + heightProperty.get()); var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty); comboBox.focusedProperty().addListener(binding); comboBox.sceneProperty().addListener(binding); widthProperty.addListener(binding); heightProperty.addListener(binding); } public static void unbindWindowsSize(JFXComboBox comboBox, IntegerProperty widthProperty, IntegerProperty heightProperty) { var binding = new WindowsSizeBidirectionalBinding(comboBox, widthProperty, heightProperty); comboBox.focusedProperty().removeListener(binding); comboBox.sceneProperty().removeListener(binding); widthProperty.removeListener(binding); heightProperty.removeListener(binding); } public static void bindAllEnabled(BooleanProperty allEnabled, BooleanProperty... children) { int itemCount = children.length; int childSelectedCount = 0; for (BooleanProperty child : children) { if (child.get()) childSelectedCount++; } allEnabled.set(childSelectedCount == itemCount); class Listener implements InvalidationListener { private int childSelectedCount; private boolean updating = false; public Listener(int childSelectedCount) { this.childSelectedCount = childSelectedCount; } @Override public void invalidated(Observable observable) { if (updating) return; updating = true; try { boolean value = ((BooleanProperty) observable).get(); if (observable == allEnabled) { for (BooleanProperty child : children) { child.setValue(value); } childSelectedCount = value ? itemCount : 0; } else { if (value) childSelectedCount++; else childSelectedCount--; allEnabled.set(childSelectedCount == itemCount); } } finally { updating = false; } } } InvalidationListener listener = new Listener(childSelectedCount); WeakInvalidationListener weakListener = new WeakInvalidationListener(listener); allEnabled.addListener(listener); for (BooleanProperty child : children) { child.addListener(weakListener); } } public static void setIcon(Stage stage) { String icon; if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { icon = "/assets/img/icon.png"; } else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { icon = "/assets/img/icon-mac.png"; } else { icon = "/assets/img/icon@4x.png"; } stage.getIcons().add(newBuiltinImage(icon)); } public static Image loadImage(Path path) throws Exception { return loadImage(path, 0, 0, false, false); } public static Image loadImage(Path path, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) throws Exception { try (var input = new BufferedInputStream(Files.newInputStream(path))) { String ext = FileUtils.getExtension(path).toLowerCase(Locale.ROOT); ImageLoader loader = ImageUtils.EXT_TO_LOADER.get(ext); if (loader == null && !ImageUtils.DEFAULT_EXTS.contains(ext)) { input.mark(ImageUtils.HEADER_BUFFER_SIZE); byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE); input.reset(); loader = ImageUtils.guessLoader(headerBuffer); } if (loader == null) loader = ImageUtils.DEFAULT; return loader.load(input, requestedWidth, requestedHeight, preserveRatio, smooth); } } public static Image loadImage(String url) throws Exception { URI uri = NetworkUtils.toURI(url); URLConnection connection = NetworkUtils.createConnection(uri); if (connection instanceof HttpURLConnection) connection = NetworkUtils.resolveConnection((HttpURLConnection) connection); try (BufferedInputStream input = new BufferedInputStream(connection.getInputStream())) { String contentType = Objects.requireNonNull(connection.getContentType(), ""); Matcher matcher = ImageUtils.CONTENT_TYPE_PATTERN.matcher(contentType); if (matcher.find()) contentType = matcher.group("type"); ImageLoader loader = ImageUtils.CONTENT_TYPE_TO_LOADER.get(contentType); if (loader == null && !ImageUtils.DEFAULT_CONTENT_TYPES.contains(contentType)) { input.mark(ImageUtils.HEADER_BUFFER_SIZE); byte[] headerBuffer = input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE); input.reset(); loader = ImageUtils.guessLoader(headerBuffer); } if (loader == null) loader = ImageUtils.DEFAULT; return loader.load(input, 0, 0, false, false); } } /** * Suppress IllegalArgumentException since the url is supposed to be correct definitely. * * @param url the url of image. The image resource should be a file within the jar. * @return the image resource within the jar. * @see org.jackhuang.hmcl.util.CrashReporter * @see ResourceNotFoundError */ public static Image newBuiltinImage(String url) { try { return builtinImageCache.computeIfAbsent(url, Image::new); } catch (IllegalArgumentException e) { throw new ResourceNotFoundError("Cannot access image: " + url, e); } } /** * Suppress IllegalArgumentException since the url is supposed to be correct definitely. * * @param url the url of image. The image resource should be a file within the jar. * @param requestedWidth the image's bounding box width * @param requestedHeight the image's bounding box height * @param preserveRatio indicates whether to preserve the aspect ratio of * the original image when scaling to fit the image within the * specified bounding box * @param smooth indicates whether to use a better quality filtering * algorithm or a faster one when scaling this image to fit within * the specified bounding box * @return the image resource within the jar. * @see org.jackhuang.hmcl.util.CrashReporter * @see ResourceNotFoundError */ public static Image newBuiltinImage(String url, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) { try { return new Image(url, requestedWidth, requestedHeight, preserveRatio, smooth); } catch (IllegalArgumentException e) { throw new ResourceNotFoundError("Cannot access image: " + url, e); } } public static Task getRemoteImageTask(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { return new CacheFileTask(url) .setSignificance(Task.TaskSignificance.MINOR) .thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth)) .setSignificance(Task.TaskSignificance.MINOR); } public static Task getRemoteImageTask(List uris, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { return new CacheFileTask(uris) .setSignificance(Task.TaskSignificance.MINOR) .thenApplyAsync(file -> loadImage(file, requestedWidth, requestedHeight, preserveRatio, smooth)) .setSignificance(Task.TaskSignificance.MINOR); } public static ObservableValue newRemoteImage(String url, int requestedWidth, int requestedHeight, boolean preserveRatio, boolean smooth) { var image = new SimpleObjectProperty(); getRemoteImageTask(url, requestedWidth, requestedHeight, preserveRatio, smooth) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { image.set(result); } else { LOG.warning("An exception encountered while loading remote image: " + url, exception); } }) .setSignificance(Task.TaskSignificance.MINOR) .start(); return image; } public static JFXButton newRaisedButton(String text) { JFXButton button = new JFXButton(text); button.getStyleClass().add("jfx-button-raised"); button.setButtonType(JFXButton.ButtonType.RAISED); return button; } public static JFXButton newBorderButton(String text) { JFXButton button = new JFXButton(text); button.getStyleClass().add("jfx-button-border"); return button; } public static JFXButton newToggleButton4(SVG icon) { JFXButton button = new JFXButton(); button.getStyleClass().add("toggle-icon4"); button.setGraphic(icon.createIcon()); return button; } public static JFXButton newToggleButton4(SVG icon, int size) { JFXButton button = new JFXButton(); button.getStyleClass().add("toggle-icon4"); button.setGraphic(icon.createIcon(size)); return button; } public static void setOnActionWithCooldown(ButtonBase button, Runnable action) { setOnActionWithCooldown(button, action, Motion.SHORT4); } public static void setOnActionWithCooldown(ButtonBase button, Runnable action, Duration cooldown) { button.setOnAction(e -> { button.setDisable(true); var pause = new PauseTransition(cooldown); pause.setOnFinished(event -> button.setDisable(false)); pause.play(); action.run(); e.consume(); }); } public static Label newSafeTruncatedLabel() { Label label = new Label(); label.setTextOverrun(OverrunStyle.CENTER_WORD_ELLIPSIS); showTooltipWhenTruncated(label); return label; } private static final String LABEL_FULL_TEXT_PROP_KEY = FXUtils.class.getName() + ".LABEL_FULL_TEXT"; public static void showTooltipWhenTruncated(Labeled labeled) { ReadOnlyBooleanProperty textTruncatedProperty = textTruncatedProperty(labeled); if (textTruncatedProperty != null) { ChangeListener listener = (observable, oldValue, newValue) -> { var label = (Labeled) ((ReadOnlyProperty) observable).getBean(); var tooltip = (Tooltip) label.getProperties().get(LABEL_FULL_TEXT_PROP_KEY); if (newValue) { if (tooltip == null) { tooltip = new Tooltip(); tooltip.textProperty().bind(label.textProperty()); label.getProperties().put(LABEL_FULL_TEXT_PROP_KEY, tooltip); } FXUtils.installFastTooltip(label, tooltip); } else if (tooltip != null) { Tooltip.uninstall(label, tooltip); } }; listener.changed(textTruncatedProperty, false, textTruncatedProperty.get()); textTruncatedProperty.addListener(listener); } } public static void applyDragListener(Node node, PathMatcher filter, Consumer> callback) { applyDragListener(node, filter, callback, null); } public static void applyDragListener(Node node, PathMatcher filter, Consumer> callback, Runnable dragDropped) { node.setOnDragOver(event -> { if (event.getGestureSource() != node && event.getDragboard().hasFiles()) { if (event.getDragboard().getFiles().stream().map(File::toPath).anyMatch(filter::matches)) event.acceptTransferModes(TransferMode.COPY_OR_MOVE); } event.consume(); }); node.setOnDragDropped(event -> { List files = event.getDragboard().getFiles(); if (files != null) { List acceptFiles = files.stream().map(File::toPath).filter(filter::matches).toList(); if (!acceptFiles.isEmpty()) { callback.accept(acceptFiles); event.setDropCompleted(true); } } if (dragDropped != null) dragDropped.run(); event.consume(); }); } public static StringConverter stringConverter(Function func) { return new StringConverter() { @Override public String toString(T object) { return object == null ? "" : func.apply(object); } @Override public T fromString(String string) { throw new UnsupportedOperationException(); } }; } public static Callback, ListCell> jfxListCellFactory(Function graphicBuilder) { return view -> new JFXListCell() { @Override public void updateItem(T item, boolean empty) { super.updateItem(item, empty); if (!empty) { setContentDisplay(ContentDisplay.GRAPHIC_ONLY); setGraphic(graphicBuilder.apply(item)); } } }; } public static ColumnConstraints getColumnFillingWidth() { ColumnConstraints constraint = new ColumnConstraints(); constraint.setFillWidth(true); return constraint; } public static ColumnConstraints getColumnHgrowing() { ColumnConstraints constraint = new ColumnConstraints(); constraint.setFillWidth(true); constraint.setHgrow(Priority.ALWAYS); return constraint; } public static final Interpolator SINE = new Interpolator() { @Override protected double curve(double t) { return Math.sin(t * Math.PI / 2); } @Override public String toString() { return "Interpolator.SINE"; } }; public static void onEscPressed(Node node, Runnable action) { node.addEventHandler(KeyEvent.KEY_PRESSED, e -> { if (e.getCode() == KeyCode.ESCAPE) { action.run(); e.consume(); } }); } public static void onClicked(Node node, Runnable action) { node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { if (e.getButton() == MouseButton.PRIMARY) { action.run(); e.consume(); } }); } public static void onSecondaryButtonClicked(Node node, Runnable action) { node.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { if (e.getButton() == MouseButton.SECONDARY) { action.run(); e.consume(); } }); } public static N prepareNode(N node) { Scene dummyScene = new Scene(node); StyleSheets.init(dummyScene); node.applyCss(); node.layout(); return node; } public static void prepareOnMouseEnter(Node node, Runnable action) { node.addEventFilter(MouseEvent.MOUSE_ENTERED, new EventHandler<>() { @Override public void handle(MouseEvent e) { node.removeEventFilter(MouseEvent.MOUSE_ENTERED, this); action.run(); } }); } public static void onScroll(Node node, List list, ToIntFunction> finder, Consumer updater ) { node.addEventHandler(ScrollEvent.SCROLL, event -> { double deltaY = event.getDeltaY(); if (deltaY == 0) return; int index = finder.applyAsInt(list); if (index < 0) return; if (deltaY > 0) // up index--; else // down index++; updater.accept(list.get((index + list.size()) % list.size())); event.consume(); }); } public static void copyOnDoubleClick(Labeled label) { label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> { if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) { String text = label.getText(); if (text != null && !text.isEmpty()) { copyText(label.getText()); e.consume(); } } }); } public static void copyText(String text) { copyText(text, i18n("message.copied")); } public static void copyText(String text, @Nullable String toastMessage) { ClipboardContent content = new ClipboardContent(); content.putString(text); Clipboard.getSystemClipboard().setContent(content); if (toastMessage != null && !Controllers.isStopped()) { Controllers.showToast(toastMessage); } } public static List parseSegment(String segment, Consumer hyperlinkAction) { if (segment.indexOf('<') < 0) return Collections.singletonList(new Text(segment)); try { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); DocumentBuilder builder = factory.newDocumentBuilder(); Document doc = builder.parse(new InputSource(new StringReader("" + segment + ""))); Element r = doc.getDocumentElement(); NodeList children = r.getChildNodes(); List texts = new ArrayList<>(); for (int i = 0; i < children.getLength(); i++) { org.w3c.dom.Node node = children.item(i); if (node instanceof Element element) { if ("a".equals(element.getTagName())) { String href = element.getAttribute("href"); Text text = new Text(element.getTextContent()); text.getStyleClass().add("hyperlink"); onClicked(text, () -> { String link = href; try { link = new URI(href).toASCIIString(); } catch (URISyntaxException ignored) { } hyperlinkAction.accept(link); }); text.setCursor(Cursor.HAND); text.setUnderline(true); texts.add(text); } else if ("b".equals(element.getTagName())) { Text text = new Text(element.getTextContent()); text.getStyleClass().add("bold"); texts.add(text); } else if ("br".equals(element.getTagName())) { texts.add(new Text("\n")); } else { throw new IllegalArgumentException("unsupported tag " + element.getTagName()); } } else { texts.add(new Text(node.getTextContent())); } } return texts; } catch (SAXException | ParserConfigurationException | IOException e) { LOG.warning("Failed to parse xml", e); return Collections.singletonList(new Text(segment)); } } public static TextFlow segmentToTextFlow(final String segment, Consumer hyperlinkAction) { TextFlow tf = new TextFlow(); tf.getChildren().setAll(parseSegment(segment, hyperlinkAction)); return tf; } public static String toWeb(Color color) { int r = (int) Math.round(color.getRed() * 255.0); int g = (int) Math.round(color.getGreen() * 255.0); int b = (int) Math.round(color.getBlue() * 255.0); return String.format("#%02x%02x%02x", r, g, b); } public static FileChooser.ExtensionFilter getImageExtensionFilter() { return new FileChooser.ExtensionFilter(i18n("extension.png"), IMAGE_EXTENSIONS.stream().map(ext -> "*." + ext).toArray(String[]::new)); } /** * Intelligently determines the popup position to prevent the menu from exceeding screen boundaries. * Supports multi-monitor setups by detecting the current screen where the component is located. * Now handles first-time popup display by forcing layout measurement. * * @param root the root node to calculate position relative to * @param popupInstance the popup instance to position * @return the optimal vertical position for the popup menu */ public static JFXPopup.PopupVPosition determineOptimalPopupPosition(Node root, JFXPopup popupInstance) { // Get the screen bounds in screen coordinates Bounds screenBounds = root.localToScreen(root.getBoundsInLocal()); // Convert Bounds to Rectangle2D for getScreensForRectangle method Rectangle2D boundsRect = new Rectangle2D( screenBounds.getMinX(), screenBounds.getMinY(), screenBounds.getWidth(), screenBounds.getHeight() ); // Find the screen that contains this component (supports multi-monitor) List screens = Screen.getScreensForRectangle(boundsRect); Screen currentScreen = screens.isEmpty() ? Screen.getPrimary() : screens.get(0); Rectangle2D visualBounds = currentScreen.getVisualBounds(); double screenHeight = visualBounds.getHeight(); double screenMinY = visualBounds.getMinY(); double itemScreenY = screenBounds.getMinY(); // Calculate available space relative to the current screen double availableSpaceAbove = itemScreenY - screenMinY; double availableSpaceBelow = screenMinY + screenHeight - itemScreenY - root.getBoundsInLocal().getHeight(); // Get popup content and ensure it's properly measured Region popupContent = popupInstance.getPopupContent(); double menuHeight; if (popupContent.getHeight() <= 0) { // Force layout measurement if height is not yet available popupContent.autosize(); popupContent.applyCss(); popupContent.layout(); // Get the measured height, or use a reasonable fallback menuHeight = popupContent.getHeight(); if (menuHeight <= 0) { // Fallback: estimate based on number of menu items // Each menu item is roughly 36px height + separators + padding menuHeight = 300; // Conservative estimate for the current menu structure } } else { menuHeight = popupContent.getHeight(); } // Add some margin for safety menuHeight += 20; return (availableSpaceAbove > menuHeight && availableSpaceBelow < menuHeight) ? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward : JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward } public static void useJFXContextMenu(TextInputControl control) { control.setContextMenu(null); PopupMenu menu = new PopupMenu(); JFXPopup popup = new JFXPopup(menu); popup.setAutoHide(true); control.setOnContextMenuRequested(e -> { boolean hasNoSelection = control.getSelectedText().isEmpty(); IconedMenuItem undo = new IconedMenuItem(SVG.UNDO, i18n("menu.undo"), control::undo, popup); IconedMenuItem redo = new IconedMenuItem(SVG.REDO, i18n("menu.redo"), control::redo, popup); IconedMenuItem cut = new IconedMenuItem(SVG.CONTENT_CUT, i18n("menu.cut"), control::cut, popup); IconedMenuItem copy = new IconedMenuItem(SVG.CONTENT_COPY, i18n("menu.copy"), control::copy, popup); IconedMenuItem paste = new IconedMenuItem(SVG.CONTENT_PASTE, i18n("menu.paste"), control::paste, popup); IconedMenuItem delete = new IconedMenuItem(SVG.DELETE, i18n("menu.deleteselection"), () -> control.replaceSelection(""), popup); IconedMenuItem selectall = new IconedMenuItem(SVG.SELECT_ALL, i18n("menu.selectall"), control::selectAll, popup); menu.getContent().setAll(undo, redo, new MenuSeparator(), cut, copy, paste, delete, new MenuSeparator(), selectall); undo.setDisable(!control.isUndoable()); redo.setDisable(!control.isRedoable()); cut.setDisable(hasNoSelection); delete.setDisable(hasNoSelection); copy.setDisable(hasNoSelection); paste.setDisable(!Clipboard.getSystemClipboard().hasString()); selectall.setDisable(control.getText() == null || control.getText().isEmpty()); JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(control, popup); popup.show(control, vPosition, JFXPopup.PopupHPosition.LEFT, e.getX(), vPosition == JFXPopup.PopupVPosition.TOP ? e.getY() : e.getY() - control.getHeight()); e.consume(); }); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2023 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; import javafx.stage.Stage; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.setting.StyleSheets; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Log4jLevel; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.logging.Logger; import org.jackhuang.hmcl.util.platform.*; import java.io.IOException; import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.attribute.FileTime; import java.time.Instant; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import static org.jackhuang.hmcl.util.DataSizeUnit.MEGABYTES; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class GameCrashWindow extends Stage { private final Version version; private final String memory; private final String total_memory; private final String java; private final LibraryAnalyzer analyzer; private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown"))); private final BooleanProperty loading = new SimpleBooleanProperty(); private final TextFlow feedbackTextFlow = new TextFlow(); private final ManagedProcess managedProcess; private final DefaultGameRepository repository; private final ProcessListener.ExitType exitType; private final LaunchOptions launchOptions; private final View view; private final StackPane stackPane; private final List logs; public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, Version version, LaunchOptions launchOptions, List logs) { Themes.applyNativeDarkMode(this); this.managedProcess = managedProcess; this.exitType = exitType; this.repository = repository; this.version = version; this.launchOptions = launchOptions; this.logs = logs; this.analyzer = LibraryAnalyzer.analyze(version, repository.getGameVersion(version).orElse(null)); memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-"); total_memory = MEGABYTES.formatBytes(SystemInfo.getTotalMemorySize()); this.java = launchOptions.getJava().getArchitecture() == Architecture.SYSTEM_ARCH ? launchOptions.getJava().getVersion() : launchOptions.getJava().getVersion() + " (" + launchOptions.getJava().getArchitecture().getDisplayName() + ")"; this.view = new View(); this.stackPane = new StackPane(view); this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction)); setScene(new Scene(stackPane, 800, 480)); StyleSheets.init(getScene()); setTitle(i18n("game.crash.title")); FXUtils.setIcon(this); analyzeCrashReport(); } @SuppressWarnings("unchecked") private void analyzeCrashReport() { loading.set(true); Task.allOf(Task.supplyAsync(() -> { String rawLog = logs.stream().map(Log::getLog).collect(Collectors.joining("\n")); // Get the crash-report from the crash-reports/xxx, or the output of console. String crashReport = null; try { crashReport = CrashReportAnalyzer.findCrashReport(rawLog); } catch (IOException e) { LOG.warning("Failed to read crash report", e); } if (crashReport == null) { crashReport = CrashReportAnalyzer.extractCrashReport(rawLog); } return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>()); }), Task.supplyAsync(() -> { Path latestLog = repository.getRunDirectory(version.getId()).resolve("logs/latest.log"); if (!Files.isReadable(latestLog)) { return pair(new HashSet(), new HashSet()); } String log; try { log = FileUtils.readTextMaybeNativeEncoding(latestLog); } catch (IOException e) { LOG.warning("Failed to read logs/latest.log", e); return pair(new HashSet(), new HashSet()); } return pair(CrashReportAnalyzer.analyze(log), CrashReportAnalyzer.findKeywordsFromCrashReport(log)); })).whenComplete(Schedulers.javafx(), (taskResult, exception) -> { loading.set(false); if (exception != null) { LOG.warning("Failed to analyze crash report", exception); reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction)); } else { EnumMap results = new EnumMap<>(CrashReportAnalyzer.Rule.class); Set keywords = new HashSet<>(); for (Pair, Set> pair : (List, Set>>) (List) taskResult) { for (CrashReportAnalyzer.Result result : pair.getKey()) { results.put(result.getRule(), result); } keywords.addAll(pair.getValue()); } List segments = new ArrayList<>(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction)); LOG.info("Number of reasons: " + results.size()); if (results.size() > 1) { segments.add(new Text("\n")); segments.addAll(FXUtils.parseSegment(i18n("game.crash.reason.multiple"), Controllers::onHyperlinkAction)); } else { segments.add(new Text("\n\n")); } for (CrashReportAnalyzer.Result result : results.values()) { String message; switch (result.getRule()) { case TOO_OLD_JAVA: message = i18n("game.crash.reason.too_old_java", CrashReportAnalyzer.getJavaVersionFromMajorVersion(Integer.parseInt(result.getMatcher().group("expected")))); break; case MOD_RESOLUTION_CONFLICT: case MOD_RESOLUTION_MISSING: case MOD_RESOLUTION_COLLECTION: message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT), translateFabricModId(result.getMatcher().group("sourcemod")), parseFabricModId(result.getMatcher().group("destmod")), parseFabricModId(result.getMatcher().group("destmod"))); break; case MOD_RESOLUTION_MISSING_MINECRAFT: message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT), translateFabricModId(result.getMatcher().group("mod")), result.getMatcher().group("version")); break; case MOD_FOREST_OPTIFINE: case TWILIGHT_FOREST_OPTIFINE: case PERFORMANT_FOREST_OPTIFINE: case JADE_FOREST_OPTIFINE: case NEOFORGE_FOREST_OPTIFINE: message = i18n("game.crash.reason.mod", "OptiFine"); LOG.info("Crash cause: " + result.getRule() + ": " + i18n("game.crash.reason.mod", "OptiFine")); break; default: message = i18n("game.crash.reason." + result.getRule().name().toLowerCase(Locale.ROOT), Arrays.stream(result.getRule().getGroupNames()).map(groupName -> result.getMatcher().group(groupName)) .toArray()); break; } LOG.info("Crash cause: " + result.getRule() + ": " + message); segments.addAll(FXUtils.parseSegment(message, Controllers::onHyperlinkAction)); segments.add(new Text("\n\n")); } if (results.isEmpty()) { if (!keywords.isEmpty()) { reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.stacktrace", String.join(", ", keywords)))); LOG.info("Crash reason unknown, but some log keywords have been found: " + String.join(", ", keywords)); } else { reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction)); LOG.info("Crash reason unknown"); } } else { feedbackTextFlow.setVisible(false); reasonTextFlow.getChildren().setAll(segments); } } }).start(); } private static final Pattern FABRIC_MOD_ID = Pattern.compile("\\{(?.*?) @ (?.*?)}"); private String translateFabricModId(String modName) { switch (modName) { case "fabricloader": return "Fabric"; case "fabric": return "Fabric API"; case "minecraft": return "Minecraft"; default: return modName; } } private String parseFabricModId(String modName) { Matcher matcher = FABRIC_MOD_ID.matcher(modName); if (matcher.find()) { String modid = matcher.group("modid"); String version = matcher.group("version"); if ("[*]".equals(version)) { return i18n("game.crash.reason.mod_resolution_mod_version.any", translateFabricModId(modid)); } else { return i18n("game.crash.reason.mod_resolution_mod_version", translateFabricModId(modid), version); } } return translateFabricModId(modName); } private void showLogWindow() { LogWindow logWindow = new LogWindow(managedProcess); logWindow.logLine(new Log(Logger.filterForbiddenToken("Command: " + new CommandBuilder().addAll(managedProcess.getCommands())), Log4jLevel.INFO)); if (managedProcess.getClasspath() != null) logWindow.logLine(new Log("ClassPath: " + managedProcess.getClasspath(), Log4jLevel.INFO)); logWindow.logLines(logs); logWindow.show(); } private void exportGameCrashInfo() { Path logFile = Paths.get("minecraft-exported-crash-info-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".zip").toAbsolutePath(); CompletableFuture.supplyAsync(() -> logs.stream().map(Log::getLog).collect(Collectors.joining("\n"))) .thenComposeAsync(logs -> { long processStartTime = managedProcess.getProcess().info() .startInstant() .map(Instant::toEpochMilli).orElseGet(() -> { try { return ManagementFactory.getRuntimeMXBean().getStartTime(); } catch (Throwable e) { LOG.warning("Failed to get process start time", e); return 0L; } }); return LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString(), path -> { try { FileTime lastModifiedTime = Files.getLastModifiedTime(path); return lastModifiedTime.toMillis() >= processStartTime; } catch (Throwable e) { LOG.warning("Failed to read file attributes", e); return false; } }); }) .handleAsync((result, exception) -> { if (exception == null) { FXUtils.showFileInExplorer(logFile); var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build(); DialogUtils.show(stackPane, dialog); } else { LOG.warning("Failed to export game crash info", exception); var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build(); DialogUtils.show(stackPane, dialog); } return null; }, Schedulers.javafx()); } private final class View extends VBox { View() { this.getStyleClass().add("game-crash-window"); HBox titlePane = new HBox(); { Label title = new Label(); HBox.setHgrow(title, Priority.ALWAYS); switch (exitType) { case JVM_ERROR: title.setText(i18n("launch.failed.cannot_create_jvm")); break; case APPLICATION_ERROR: title.setText(i18n("launch.failed.exited_abnormally")); break; case SIGKILL: title.setText(i18n("launch.failed.sigkill")); break; } titlePane.setAlignment(Pos.CENTER); titlePane.getStyleClass().addAll("jfx-tool-bar-second", "depth-1", "padding-8"); titlePane.getChildren().setAll(title); } HBox infoPane = new HBox(8); { infoPane.setPadding(new Insets(8)); infoPane.setAlignment(Pos.CENTER_LEFT); TwoLineListItem launcher = new TwoLineListItem(); launcher.getStyleClass().setAll("two-line-item-second-large"); launcher.setTitle(i18n("launcher")); launcher.setSubtitle(Metadata.VERSION); TwoLineListItem version = new TwoLineListItem(); version.getStyleClass().setAll("two-line-item-second-large"); version.setTitle(i18n("game.version")); version.setSubtitle(GameCrashWindow.this.version.getId()); TwoLineListItem total_memory = new TwoLineListItem(); total_memory.getStyleClass().setAll("two-line-item-second-large"); total_memory.setTitle(i18n("settings.physical_memory")); total_memory.setSubtitle(GameCrashWindow.this.total_memory); TwoLineListItem memory = new TwoLineListItem(); memory.getStyleClass().setAll("two-line-item-second-large"); memory.setTitle(i18n("settings.memory")); memory.setSubtitle(GameCrashWindow.this.memory); TwoLineListItem java = new TwoLineListItem(); java.getStyleClass().setAll("two-line-item-second-large"); java.setTitle("Java"); java.setSubtitle(GameCrashWindow.this.java); TwoLineListItem os = new TwoLineListItem(); os.getStyleClass().setAll("two-line-item-second-large"); os.setTitle(i18n("system.operating_system")); os.setSubtitle(Lang.requireNonNullElse(OperatingSystem.OS_RELEASE_NAME, OperatingSystem.SYSTEM_NAME)); TwoLineListItem arch = new TwoLineListItem(); arch.getStyleClass().setAll("two-line-item-second-large"); arch.setTitle(i18n("system.architecture")); arch.setSubtitle(Architecture.SYSTEM_ARCH.getDisplayName()); infoPane.getChildren().setAll(launcher, version, total_memory, memory, java, os, arch); } HBox moddedPane = new HBox(8); { moddedPane.setPadding(new Insets(8)); moddedPane.setAlignment(Pos.CENTER_LEFT); for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { if (!type.getPatchId().isEmpty()) { analyzer.getVersion(type).ifPresent(ver -> { TwoLineListItem item = new TwoLineListItem(); item.getStyleClass().setAll("two-line-item-second-large"); item.setTitle(i18n("install.installer." + type.getPatchId())); item.setSubtitle(ver); moddedPane.getChildren().add(item); }); } } } VBox gameDirPane = new VBox(8); { TwoLineListItem gameDir = new TwoLineListItem(); gameDir.getStyleClass().setAll("two-line-item-second-large"); gameDir.setTitle(i18n("game.directory")); gameDir.setSubtitle(launchOptions.getGameDir().toAbsolutePath().toString()); FXUtils.installFastTooltip(gameDir, i18n("game.directory")); TwoLineListItem javaDir = new TwoLineListItem(); javaDir.getStyleClass().setAll("two-line-item-second-large"); javaDir.setTitle(i18n("settings.game.java_directory")); javaDir.setSubtitle(launchOptions.getJava().getBinary().toAbsolutePath().toString()); FXUtils.installFastTooltip(javaDir, i18n("settings.game.java_directory")); Label reasonTitle = new Label(i18n("game.crash.reason")); reasonTitle.getStyleClass().add("two-line-item-second-large-title"); ScrollPane reasonPane = new ScrollPane(reasonTextFlow); reasonTextFlow.getStyleClass().add("crash-reason-text-flow"); reasonPane.setFitToWidth(true); reasonPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); reasonPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); feedbackTextFlow.getStyleClass().add("crash-reason-text-flow"); gameDirPane.setPadding(new Insets(8)); VBox.setVgrow(gameDirPane, Priority.ALWAYS); FXUtils.onChangeAndOperate(feedbackTextFlow.visibleProperty(), visible -> { if (visible) { gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonPane, feedbackTextFlow)); } else { gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonPane)); } }); } HBox toolBar = new HBox(); VBox.setMargin(toolBar, new Insets(0, 0, 4, 0)); { JFXButton exportGameCrashInfoButton = FXUtils.newRaisedButton(i18n("logwindow.export_game_crash_logs")); exportGameCrashInfoButton.setOnAction(e -> exportGameCrashInfo()); JFXButton logButton = FXUtils.newRaisedButton(i18n("logwindow.title")); logButton.setOnAction(e -> showLogWindow()); JFXButton helpButton = FXUtils.newRaisedButton(i18n("help")); helpButton.setOnAction(e -> FXUtils.openLink(Metadata.CONTACT_URL)); FXUtils.installFastTooltip(helpButton, i18n("logwindow.help")); toolBar.setPadding(new Insets(8)); toolBar.setSpacing(8); toolBar.getStyleClass().add("jfx-tool-bar"); toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton, helpButton); } getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2024 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import javafx.scene.Cursor; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; import org.jackhuang.hmcl.util.StringUtils; import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; import java.net.URI; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class HTMLRenderer { private static URI resolveLink(Node linkNode) { String href = linkNode.absUrl("href"); if (href.isEmpty()) return null; try { return new URI(href); } catch (Throwable e) { return null; } } private final List children = new ArrayList<>(); private final List stack = new ArrayList<>(); private boolean bold; private boolean italic; private boolean underline; private boolean strike; private boolean highlight; private String headerLevel; private Node hyperlink; private final Consumer onClickHyperlink; public HTMLRenderer(Consumer onClickHyperlink) { this.onClickHyperlink = onClickHyperlink; } private void updateStyle() { bold = false; italic = false; underline = false; strike = false; highlight = false; headerLevel = null; hyperlink = null; for (Node node : stack) { String nodeName = node.nodeName(); switch (nodeName) { case "b": case "strong": bold = true; break; case "i": case "em": italic = true; break; case "ins": underline = true; break; case "del": strike = true; break; case "mark": highlight = true; break; case "a": hyperlink = node; break; case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": headerLevel = nodeName; break; } } } private void pushNode(Node node) { stack.add(node); updateStyle(); } private void popNode() { stack.remove(stack.size() - 1); updateStyle(); } private void applyStyle(Text text) { if (hyperlink != null) { URI target = resolveLink(hyperlink); if (target != null) { FXUtils.onClicked(text, () -> onClickHyperlink.accept(target)); text.setCursor(Cursor.HAND); } text.getStyleClass().add("html-hyperlink"); } if (hyperlink != null || underline) text.setUnderline(true); if (strike) text.setStrikethrough(true); if (bold || highlight) text.getStyleClass().add("html-bold"); if (italic) text.getStyleClass().add("html-italic"); if (headerLevel != null) text.getStyleClass().add("html-" + headerLevel); } private void appendText(String text) { Text textNode = new Text(text); applyStyle(textNode); children.add(textNode); } private void appendAutoLineBreak(String text) { AutoLineBreak textNode = new AutoLineBreak(text); applyStyle(textNode); children.add(textNode); } private void appendImage(Node node) { String src = node.absUrl("src"); String alt = node.attr("alt"); if (StringUtils.isNotBlank(src)) { String widthAttr = node.attr("width"); String heightAttr = node.attr("height"); int width = 0; int height = 0; if (!widthAttr.isEmpty() && !heightAttr.isEmpty()) { try { width = (int) Double.parseDouble(widthAttr); height = (int) Double.parseDouble(heightAttr); } catch (NumberFormatException ignored) { } if (width <= 0 || height <= 0) { width = 0; height = 0; } } try { Image image = FXUtils.getRemoteImageTask(src, width, height, true, true) .run(); if (image == null) throw new AssertionError("Image loading task returned null"); ImageView imageView = new ImageView(image); if (hyperlink != null) { URI target = resolveLink(hyperlink); if (target != null) { FXUtils.onClicked(imageView, () -> onClickHyperlink.accept(target)); imageView.setCursor(Cursor.HAND); } } children.add(imageView); return; } catch (Throwable e) { LOG.warning("Failed to load image: " + src, e); } } if (!alt.isEmpty()) appendText(alt); } public void appendNode(Node node) { if (node instanceof TextNode) { appendText(((TextNode) node).text()); } String name = node.nodeName(); switch (name) { case "img": appendImage(node); break; case "li": appendText("\n \u2022 "); break; case "dt": appendText(" "); break; case "p": case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": case "tr": if (!children.isEmpty()) appendAutoLineBreak("\n\n"); break; } if (node.childNodeSize() > 0) { pushNode(node); for (Node childNode : node.childNodes()) { appendNode(childNode); } popNode(); } switch (name) { case "br": case "dd": case "p": case "h1": case "h2": case "h3": case "h4": case "h5": case "h6": appendAutoLineBreak("\n"); break; } } private static boolean isSpacing(String text) { if (text == null) return true; for (int i = 0; i < text.length(); i++) { char ch = text.charAt(i); if (ch != ' ' && ch != '\t') return false; } return true; } public void mergeLineBreaks() { for (int i = 0; i < this.children.size(); i++) { javafx.scene.Node child = this.children.get(i); if (child instanceof AutoLineBreak) { int lastAutoLineBreak = -1; for (int j = i + 1; j < this.children.size(); j++) { javafx.scene.Node otherChild = this.children.get(j); if (otherChild instanceof AutoLineBreak) { lastAutoLineBreak = j; } else if (otherChild instanceof Text && isSpacing(((Text) otherChild).getText())) { // do nothing } else { break; } } if (lastAutoLineBreak > 0) { this.children.subList(i + 1, lastAutoLineBreak + 1).clear(); if (((Text) child).getText().length() == 1) { ((Text) child).setText("\n\n"); } } } } } public TextFlow render() { TextFlow textFlow = new TextFlow(); textFlow.getStyleClass().add("html"); textFlow.getChildren().setAll(children); return textFlow; } private static final class AutoLineBreak extends Text { public AutoLineBreak(String text) { super(text); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXRippler; import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Cursor; import javafx.scene.control.Control; import javafx.scene.control.Label; import javafx.scene.control.Skin; import javafx.scene.control.SkinBase; import javafx.scene.input.MouseButton; import javafx.scene.layout.*; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.setting.VersionIconType; import org.jackhuang.hmcl.ui.construct.ImageContainer; import org.jackhuang.hmcl.ui.construct.RipplerContainer; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /** * @author huangyuhui */ public class InstallerItem extends Control { private final String id; private final VersionIconType iconType; private final Style style; private final ObjectProperty versionProperty = new SimpleObjectProperty<>(this, "version", null); private final ObjectProperty resolvedStateProperty = new SimpleObjectProperty<>(this, "resolvedState", InstallableState.INSTANCE); private final ObjectProperty onInstall = new SimpleObjectProperty<>(this, "onInstall"); private final ObjectProperty onRemove = new SimpleObjectProperty<>(this, "onRemove"); public sealed interface State { } public static final class InstallableState implements State { public static final InstallableState INSTANCE = new InstallableState(); private InstallableState() { } } public record IncompatibleState(String incompatibleItemName, String incompatibleItemVersion) implements State { } public record InstalledState(String version, boolean external, boolean incompatibleWithGame) implements State { } public enum Style { LIST_ITEM, CARD, } public InstallerItem(LibraryAnalyzer.LibraryType id, Style style) { this(id.getPatchId(), style); } public InstallerItem(String id, Style style) { this.id = id; this.style = style; iconType = switch (id) { case "game" -> VersionIconType.GRASS; case "fabric", "fabric-api" -> VersionIconType.FABRIC; case "legacyfabric", "legacyfabric-api" -> VersionIconType.LEGACY_FABRIC; case "forge" -> VersionIconType.FORGE; case "cleanroom" -> VersionIconType.CLEANROOM; case "liteloader" -> VersionIconType.CHICKEN; case "optifine" -> VersionIconType.OPTIFINE; case "quilt", "quilt-api" -> VersionIconType.QUILT; case "neoforge" -> VersionIconType.NEO_FORGE; default -> null; }; } public String getLibraryId() { return id; } public ObjectProperty versionProperty() { return versionProperty; } public ObjectProperty resolvedStateProperty() { return resolvedStateProperty; } public ObjectProperty onInstallProperty() { return onInstall; } public Runnable getOnInstall() { return onInstall.get(); } public void setOnInstall(Runnable onInstall) { this.onInstall.set(onInstall); } public ObjectProperty onRemoveProperty() { return onRemove; } public Runnable getOnRemove() { return onRemove.get(); } public void setOnRemove(Runnable onRemove) { this.onRemove.set(onRemove); } @Override protected Skin createDefaultSkin() { return new InstallerItemSkin(this); } public final static class InstallerItemGroup { private final InstallerItem game; private final InstallerItem[] libraries; private Set getIncompatibles(Map> incompatibleMap, InstallerItem item) { return incompatibleMap.computeIfAbsent(item, it -> new HashSet<>()); } private void addIncompatibles(Map> incompatibleMap, InstallerItem item, InstallerItem... others) { Set set = getIncompatibles(incompatibleMap, item); for (InstallerItem other : others) { set.add(other); getIncompatibles(incompatibleMap, other).add(item); } } private void mutualIncompatible(Map> incompatibleMap, InstallerItem... items) { for (InstallerItem item : items) { Set set = getIncompatibles(incompatibleMap, item); for (InstallerItem item2 : items) { if (item2 != item) { set.add(item2); } } } } public InstallerItemGroup(String gameVersion, Style style) { game = new InstallerItem(MINECRAFT, style); InstallerItem fabric = new InstallerItem(FABRIC, style); InstallerItem fabricApi = new InstallerItem(FABRIC_API, style); InstallerItem forge = new InstallerItem(FORGE, style); InstallerItem cleanroom = new InstallerItem(CLEANROOM, style); InstallerItem legacyfabric = new InstallerItem(LEGACY_FABRIC, style); InstallerItem legacyfabricApi = new InstallerItem(LEGACY_FABRIC_API, style); InstallerItem neoForge = new InstallerItem(NEO_FORGE, style); InstallerItem liteLoader = new InstallerItem(LITELOADER, style); InstallerItem optiFine = new InstallerItem(OPTIFINE, style); InstallerItem quilt = new InstallerItem(QUILT, style); InstallerItem quiltApi = new InstallerItem(QUILT_API, style); Map> incompatibleMap = new HashMap<>(); mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom, legacyfabric); addIncompatibles(incompatibleMap, liteLoader, fabric, quilt, neoForge, cleanroom, legacyfabric); addIncompatibles(incompatibleMap, optiFine, fabric, quilt, neoForge, cleanroom, liteLoader, legacyfabric); addIncompatibles(incompatibleMap, fabricApi, forge, quiltApi, neoForge, liteLoader, optiFine, cleanroom, legacyfabric, legacyfabricApi); addIncompatibles(incompatibleMap, quiltApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom, legacyfabric, legacyfabricApi); addIncompatibles(incompatibleMap, legacyfabricApi, forge, fabric, fabricApi, neoForge, liteLoader, optiFine, cleanroom, quilt, quiltApi); for (Map.Entry> entry : incompatibleMap.entrySet()) { InstallerItem item = entry.getKey(); Set incompatibleItems = entry.getValue(); Observable[] bindings = new Observable[incompatibleItems.size() + 1]; bindings[0] = item.versionProperty; int i = 1; for (InstallerItem other : incompatibleItems) { bindings[i++] = other.versionProperty; } item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> { InstalledState itemVersion = item.versionProperty.get(); if (itemVersion != null) { return itemVersion; } for (InstallerItem other : incompatibleItems) { InstalledState otherVersion = other.versionProperty.get(); if (otherVersion != null) { return new IncompatibleState(other.id, otherVersion.version); } } return InstallableState.INSTANCE; }, bindings)); } if (gameVersion != null) { game.versionProperty.set(new InstalledState(gameVersion, false, false)); } InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, legacyfabric, legacyfabricApi, cleanroom}; for (InstallerItem item : all) { if (!item.resolvedStateProperty.isBound()) { item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> { InstalledState itemVersion = item.versionProperty.get(); if (itemVersion != null) { return itemVersion; } return InstallableState.INSTANCE; }, item.versionProperty)); } } if (gameVersion == null) { this.libraries = all; } else if (gameVersion.equals("1.12.2")) { this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine}; } else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) { this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi}; } else { this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; } } public InstallerItem getGame() { return game; } public InstallerItem[] getLibraries() { return libraries; } } private static final class InstallerItemSkin extends SkinBase { private static final PseudoClass LIST_ITEM = PseudoClass.getPseudoClass("list-item"); private static final PseudoClass CARD = PseudoClass.getPseudoClass("card"); @SuppressWarnings({"FieldCanBeLocal", "unused"}) private final ChangeListener holder; InstallerItemSkin(InstallerItem control) { super(control); Pane pane; if (control.style == Style.CARD) { pane = new VBox(); holder = FXUtils.onWeakChangeAndOperate(pane.widthProperty(), v -> FXUtils.setLimitHeight(pane, v.doubleValue() * 0.7)); } else { pane = new HBox(); holder = null; } pane.getStyleClass().add("installer-item"); RipplerContainer container = new RipplerContainer(pane); container.setPosition(JFXRippler.RipplerPos.BACK); StackPane paneWrapper = new StackPane(); paneWrapper.getStyleClass().add("installer-item-wrapper"); paneWrapper.getChildren().setAll(container); getChildren().setAll(paneWrapper); pane.pseudoClassStateChanged(LIST_ITEM, control.style == Style.LIST_ITEM); pane.pseudoClassStateChanged(CARD, control.style == Style.CARD); paneWrapper.pseudoClassStateChanged(CARD, control.style == Style.CARD); if (control.iconType != null) { var imageContainer = new ImageContainer(32); imageContainer.setImage(control.iconType.getIcon()); imageContainer.setMouseTransparent(true); imageContainer.getStyleClass().add("installer-item-image"); pane.getChildren().add(imageContainer); if (control.style == Style.CARD) { VBox.setMargin(imageContainer, new Insets(8, 0, 16, 0)); } } Label nameLabel = new Label(); nameLabel.getStyleClass().add("installer-item-name"); nameLabel.setMouseTransparent(true); pane.getChildren().add(nameLabel); nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.id) ? i18n("install.installer." + control.id) : control.id); HBox.setMargin(nameLabel, new Insets(0, 4, 0, 4)); Label statusLabel = new Label(); statusLabel.getStyleClass().add("installer-item-status"); statusLabel.setMouseTransparent(true); pane.getChildren().add(statusLabel); HBox.setHgrow(statusLabel, Priority.ALWAYS); statusLabel.textProperty().bind(Bindings.createStringBinding(() -> { State state = control.resolvedStateProperty.get(); if (state instanceof InstalledState installedState) { if (installedState.incompatibleWithGame) { return i18n("install.installer.change_version", installedState.version); } if (installedState.external) { return i18n("install.installer.external_version", installedState.version); } return i18n("install.installer.version", installedState.version); } else if (state instanceof InstallableState) { return control.style == Style.CARD ? i18n("install.installer.do_not_install") : i18n("install.installer.not_installed"); } else if (state instanceof IncompatibleState incompatibleState) { return i18n("install.installer.incompatible", i18n("install.installer." + incompatibleState.incompatibleItemName)); } else { throw new AssertionError("Unknown state type: " + state.getClass()); } }, control.resolvedStateProperty)); BorderPane.setMargin(statusLabel, new Insets(0, 0, 0, 8)); BorderPane.setAlignment(statusLabel, Pos.CENTER_LEFT); HBox buttonsContainer = new HBox(); buttonsContainer.setPickOnBounds(false); buttonsContainer.setSpacing(8); buttonsContainer.setAlignment(Pos.CENTER); pane.getChildren().add(buttonsContainer); JFXButton removeButton = FXUtils.newToggleButton4(SVG.CLOSE); if (control.id.equals(MINECRAFT.getPatchId())) { removeButton.setVisible(false); } else { removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> { State state = control.resolvedStateProperty.get(); return state instanceof InstalledState installedState && !installedState.external; }, control.resolvedStateProperty)); } removeButton.managedProperty().bind(removeButton.visibleProperty()); removeButton.setOnAction(e -> { Runnable onRemove = control.getOnRemove(); if (onRemove != null) onRemove.run(); }); buttonsContainer.getChildren().add(removeButton); JFXButton installButton = new JFXButton(); installButton.graphicProperty().bind(Bindings.createObjectBinding(() -> control.resolvedStateProperty.get() instanceof InstallableState ? SVG.ARROW_FORWARD.createIcon() : SVG.UPDATE.createIcon(), control.resolvedStateProperty )); installButton.getStyleClass().add("toggle-icon4"); installButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> { if (control.getOnInstall() == null) { return false; } State state = control.resolvedStateProperty.get(); if (state instanceof InstallableState) { return true; } if (state instanceof InstalledState) { return !((InstalledState) state).external; } return false; }, control.resolvedStateProperty, control.onInstall)); installButton.managedProperty().bind(installButton.visibleProperty()); installButton.setOnAction(e -> { Runnable onInstall = control.getOnInstall(); if (onInstall != null) onInstall.run(); }); buttonsContainer.getChildren().add(installButton); FXUtils.onChangeAndOperate(installButton.visibleProperty(), clickable -> { if (clickable) { container.setOnMouseClicked(event -> { Runnable onInstall = control.getOnInstall(); if (onInstall != null && event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 1) { onInstall.run(); event.consume(); } }); pane.setCursor(Cursor.HAND); } else { container.setOnMouseClicked(null); pane.setCursor(Cursor.DEFAULT); } }); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/ListPageBase.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.event.Event; import javafx.event.EventHandler; import javafx.scene.control.Control; import org.jackhuang.hmcl.ui.animation.TransitionPane; import static org.jackhuang.hmcl.ui.construct.SpinnerPane.FAILED_ACTION; public class ListPageBase extends Control implements TransitionPane.Cacheable { private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList()); private final BooleanProperty loading = new SimpleBooleanProperty(this, "loading", false); private final StringProperty failedReason = new SimpleStringProperty(this, "failed"); public ObservableList getItems() { return items.get(); } public void setItems(ObservableList items) { this.items.set(items); } public ListProperty itemsProperty() { return items; } public boolean isLoading() { return loading.get(); } public void setLoading(boolean loading) { this.loading.set(loading); } public BooleanProperty loadingProperty() { return loading; } public String getFailedReason() { return failedReason.get(); } public StringProperty failedReasonProperty() { return failedReason; } public void setFailedReason(String failedReason) { this.failedReason.set(failedReason); } public final ObjectProperty> onFailedActionProperty() { return onFailedAction; } public final void setOnFailedAction(EventHandler value) { onFailedActionProperty().set(value); } public final EventHandler getOnFailedAction() { return onFailedActionProperty().get(); } private ObjectProperty> onFailedAction = new SimpleObjectProperty>(this, "onFailedAction") { @Override protected void invalidated() { setEventHandler(FAILED_ACTION, get()); } }; } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/LogWindow.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.*; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.input.KeyCode; import javafx.scene.input.MouseButton; import javafx.scene.layout.*; import javafx.stage.Stage; import org.jackhuang.hmcl.game.GameDumpGenerator; import org.jackhuang.hmcl.game.Log; import org.jackhuang.hmcl.setting.StyleSheets; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.util.CircularArrayList; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Log4jLevel; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.platform.ManagedProcess; import org.jackhuang.hmcl.util.platform.SystemUtils; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.stream.Collectors; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author huangyuhui */ public final class LogWindow extends Stage { private static final Log4jLevel[] LEVELS = {Log4jLevel.FATAL, Log4jLevel.ERROR, Log4jLevel.WARN, Log4jLevel.INFO, Log4jLevel.DEBUG}; private final CircularArrayList logs; private final Map levelCountMap = new EnumMap<>(Log4jLevel.class); private final Map levelShownMap = new EnumMap<>(Log4jLevel.class); { for (Log4jLevel level : Log4jLevel.values()) { levelCountMap.put(level, new SimpleIntegerProperty()); levelShownMap.put(level, new SimpleBooleanProperty(true)); } } private final LogWindowImpl impl; private final ManagedProcess gameProcess; public LogWindow(ManagedProcess gameProcess) { this(gameProcess, new CircularArrayList<>()); } public LogWindow(ManagedProcess gameProcess, CircularArrayList logs) { Themes.applyNativeDarkMode(this); this.logs = logs; this.impl = new LogWindowImpl(); setScene(new Scene(impl, 800, 480)); StyleSheets.init(getScene()); setTitle(i18n("logwindow.title")); FXUtils.setIcon(this); for (SimpleBooleanProperty property : levelShownMap.values()) { property.addListener(o -> shakeLogs()); } this.gameProcess = gameProcess; } public void logLine(Log log) { Log4jLevel level = log.getLevel(); logs.add(log); if (levelShownMap.get(level).get()) impl.listView.getItems().add(log); SimpleIntegerProperty property = levelCountMap.get(log.getLevel()); property.set(property.get() + 1); checkLogCount(); autoScroll(); } public void logLines(List logs) { for (Log log : logs) { Log4jLevel level = log.getLevel(); this.logs.add(log); if (levelShownMap.get(level).get()) impl.listView.getItems().add(log); SimpleIntegerProperty property = levelCountMap.get(log.getLevel()); property.set(property.get() + 1); } checkLogCount(); autoScroll(); } private void shakeLogs() { impl.listView.getItems().setAll(logs.stream().filter(log -> levelShownMap.get(log.getLevel()).get()).collect(Collectors.toList())); autoScroll(); } private void checkLogCount() { int nRemove = logs.size() - Log.getLogLines(); if (nRemove <= 0) return; ObservableList items = impl.listView.getItems(); int itemsSize = items.size(); int count = 0; for (int i = 0; i < nRemove; i++) { Log removedLog = logs.removeFirst(); if (itemsSize > count && items.get(count) == removedLog) count++; } items.remove(0, count); } private void autoScroll() { if (!impl.listView.getItems().isEmpty() && impl.autoScroll.get()) impl.listView.scrollTo(impl.listView.getItems().size() - 1); } private final class LogWindowImpl extends Control { private final ListView listView = new JFXListView<>(); private final BooleanProperty autoScroll = new SimpleBooleanProperty(); private final StringProperty[] buttonText = new StringProperty[LEVELS.length]; private final BooleanProperty[] showLevel = new BooleanProperty[LEVELS.length]; private final JFXComboBox cboLines = new JFXComboBox<>(); private final StackPane stackPane = new StackPane(); LogWindowImpl() { getStyleClass().add("log-window"); listView.getProperties().put("no-smooth-scrolling", true); listView.setItems(FXCollections.observableList(new CircularArrayList<>(logs.size()))); for (int i = 0; i < LEVELS.length; i++) { buttonText[i] = new SimpleStringProperty(); showLevel[i] = new SimpleBooleanProperty(true); } cboLines.getItems().setAll(500, 2000, 5000, 10000); cboLines.setValue(Log.getLogLines()); cboLines.getSelectionModel().selectedItemProperty().addListener((a, b, newValue) -> config().setLogLines(newValue)); for (int i = 0; i < LEVELS.length; ++i) { buttonText[i].bind(Bindings.concat(levelCountMap.get(LEVELS[i]), " " + LEVELS[i].name().toLowerCase(Locale.ROOT) + "s")); levelShownMap.get(LEVELS[i]).bind(showLevel[i]); } } private void onTerminateGame() { LogWindow.this.gameProcess.stop(); } private void onClear() { impl.listView.getItems().clear(); logs.clear(); } private void onExportLogs() { thread(() -> { Path logFile = Paths.get("minecraft-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".log").toAbsolutePath(); try { Files.write(logFile, logs.stream().map(Log::getLog).collect(Collectors.toList())); } catch (IOException e) { LOG.warning("Failed to export logs", e); return; } Platform.runLater(() -> { var dialog = new MessageDialogPane.Builder(i18n("settings.launcher.launcher_log.export.success", logFile), i18n("message.success"), MessageDialogPane.MessageType.SUCCESS).ok(null).build(); DialogUtils.show(stackPane, dialog); }); FXUtils.showFileInExplorer(logFile); }); } private void onExportDump(SpinnerPane pane) { assert SystemUtils.supportJVMAttachment(); pane.setLoading(true); thread(() -> { Path dumpFile = Paths.get("minecraft-exported-jstack-dump-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".log").toAbsolutePath(); try { if (gameProcess.isRunning()) { GameDumpGenerator.writeDumpTo(gameProcess.getProcess().pid(), dumpFile); FXUtils.showFileInExplorer(dumpFile); } } catch (Throwable e) { LOG.warning("Failed to create minecraft jstack dump", e); Platform.runLater(() -> { var dialog = new MessageDialogPane.Builder(i18n("logwindow.export_dump") + "\n" + StringUtils.getStackTrace(e), i18n("message.error"), MessageDialogPane.MessageType.ERROR).ok(null).build(); DialogUtils.show(stackPane, dialog); }); } Platform.runLater(() -> pane.setLoading(false)); }); } @Override protected Skin createDefaultSkin() { return new LogWindowSkin(this); } } private static final class LogWindowSkin extends SkinBase { private static final PseudoClass EMPTY = PseudoClass.getPseudoClass("empty"); private static final PseudoClass FATAL = PseudoClass.getPseudoClass("fatal"); private static final PseudoClass ERROR = PseudoClass.getPseudoClass("error"); private static final PseudoClass WARN = PseudoClass.getPseudoClass("warn"); private static final PseudoClass INFO = PseudoClass.getPseudoClass("info"); private static final PseudoClass DEBUG = PseudoClass.getPseudoClass("debug"); private static final PseudoClass TRACE = PseudoClass.getPseudoClass("trace"); private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); private final Set> selected = new HashSet<>(); private final JFXSnackbar snackbar = new JFXSnackbar(); LogWindowSkin(LogWindowImpl control) { super(control); VBox vbox = new VBox(3); vbox.setPadding(new Insets(3, 0, 3, 0)); getSkinnable().stackPane.getChildren().setAll(vbox); getChildren().setAll(getSkinnable().stackPane); snackbar.registerSnackbarContainer(getSkinnable().stackPane); { BorderPane borderPane = new BorderPane(); borderPane.setPadding(new Insets(0, 3, 0, 3)); { HBox hBox = new HBox(3); hBox.setPadding(new Insets(0, 0, 0, 4)); hBox.setAlignment(Pos.CENTER_LEFT); Label label = new Label(i18n("logwindow.show_lines")); hBox.getChildren().setAll(label, control.cboLines); borderPane.setLeft(hBox); } { HBox hBox = new HBox(3); for (int i = 0; i < LEVELS.length; i++) { ToggleButton button = new ToggleButton(); button.getStyleClass().addAll("log-toggle", LEVELS[i].name().toLowerCase(Locale.ROOT)); button.textProperty().bind(control.buttonText[i]); button.setSelected(true); control.showLevel[i].bind(button.selectedProperty()); hBox.getChildren().add(button); } borderPane.setRight(hBox); } vbox.getChildren().add(borderPane); } { ListView listView = control.listView; listView.getItems().addListener((InvalidationListener) observable -> { if (!listView.getItems().isEmpty() && control.autoScroll.get()) listView.scrollTo(listView.getItems().size() - 1); }); listView.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\"; -fx-font-size: " + config().getFontSize() + "px;"); listView.setCellFactory(x -> new ListCell<>() { { x.setSelectionModel(new NoneMultipleSelectionModel<>()); getStyleClass().add("log-window-list-cell"); Region clippedContainer = (Region) listView.lookup(".clipped-container"); if (clippedContainer != null) { maxWidthProperty().bind(clippedContainer.widthProperty()); prefWidthProperty().bind(clippedContainer.widthProperty()); } setPadding(new Insets(2)); setWrapText(true); setGraphic(null); setOnMouseClicked(event -> { if (event.getButton() != MouseButton.PRIMARY) return; if (!event.isControlDown()) { for (ListCell logListCell : selected) { if (logListCell != this) { logListCell.pseudoClassStateChanged(SELECTED, false); if (logListCell.getItem() != null) { logListCell.getItem().setSelected(false); } } } selected.clear(); } selected.add(this); pseudoClassStateChanged(SELECTED, true); if (getItem() != null) { getItem().setSelected(true); } event.consume(); }); } @Override protected void updateItem(Log item, boolean empty) { super.updateItem(item, empty); pseudoClassStateChanged(EMPTY, empty); pseudoClassStateChanged(FATAL, !empty && item.getLevel() == Log4jLevel.FATAL); pseudoClassStateChanged(ERROR, !empty && item.getLevel() == Log4jLevel.ERROR); pseudoClassStateChanged(WARN, !empty && item.getLevel() == Log4jLevel.WARN); pseudoClassStateChanged(INFO, !empty && item.getLevel() == Log4jLevel.INFO); pseudoClassStateChanged(DEBUG, !empty && item.getLevel() == Log4jLevel.DEBUG); pseudoClassStateChanged(TRACE, !empty && item.getLevel() == Log4jLevel.TRACE); pseudoClassStateChanged(SELECTED, !empty && item.isSelected()); if (empty) { setText(null); } else { setText(item.getLog()); } } }); listView.setOnKeyPressed(event -> { if (event.isControlDown() && event.getCode() == KeyCode.C) { StringBuilder stringBuilder = new StringBuilder(); for (Log item : listView.getItems()) { if (item != null && item.isSelected()) { if (item.getLog() != null) stringBuilder.append(item.getLog()); stringBuilder.append('\n'); } } FXUtils.copyText(stringBuilder.toString(), null); snackbar.fireEvent(new JFXSnackbar.SnackbarEvent(new JFXSnackbarLayout(i18n("message.copied")))); } }); VBox.setVgrow(listView, Priority.ALWAYS); vbox.getChildren().add(listView); } { BorderPane bottom = new BorderPane(); HBox hBox = new HBox(3); bottom.setRight(hBox); hBox.setAlignment(Pos.CENTER_RIGHT); hBox.setPadding(new Insets(0, 3, 0, 3)); JFXCheckBox autoScrollCheckBox = new JFXCheckBox(i18n("logwindow.autoscroll")); autoScrollCheckBox.setSelected(true); control.autoScroll.bind(autoScrollCheckBox.selectedProperty()); JFXButton exportLogsButton = new JFXButton(i18n("button.export")); exportLogsButton.setOnAction(e -> getSkinnable().onExportLogs()); JFXButton terminateButton = new JFXButton(i18n("logwindow.terminate_game")); terminateButton.setOnAction(e -> getSkinnable().onTerminateGame()); SpinnerPane exportDumpPane = new SpinnerPane(); exportDumpPane.getStyleClass().add("small-spinner-pane"); JFXButton exportDumpButton = new JFXButton(i18n("logwindow.export_dump")); if (SystemUtils.supportJVMAttachment()) { exportDumpButton.setOnAction(e -> getSkinnable().onExportDump(exportDumpPane)); } else { FXUtils.installFastTooltip(exportDumpPane, i18n("logwindow.export_dump.no_dependency")); exportDumpButton.setDisable(true); } exportDumpPane.setContent(exportDumpButton); JFXButton clearButton = new JFXButton(i18n("button.clear")); clearButton.setOnAction(e -> getSkinnable().onClear()); hBox.getChildren().setAll(autoScrollCheckBox, exportLogsButton, terminateButton, exportDumpPane, clearButton); vbox.getChildren().add(bottom); } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/SVG.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import javafx.beans.value.ObservableValue; import javafx.scene.paint.Paint; import javafx.scene.shape.SVGPath; /// All vector icons used in the launcher. /// /// Unless otherwise stated, /// these icons are from Material Symbols, /// with a style of outlined, a weight of 400, a grade of 0, and an optical size of 24 px. /// The view boxes of all icons are normalized to `0 0 24 24`. public enum SVG { NONE(""), // Empty Icon ADD("M11 13H5V11H11V5H13V11H19V13H13V19H11V13Z"), ADD_CIRCLE("M11 17H13V13H17V11H13V7H11V11H7V13H11V17ZM12 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"), ALPHA_CIRCLE("M11,7H13A2,2 0 0,1 15,9V17H13V13H11V17H9V9A2,2 0 0,1 11,7M11,9V11H13V9H11M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2Z"), // Not Material ARCHIVE("M12 18 16 14 14.6 12.6 13 14.2V10H11V14.2L9.4 12.6 8 14 12 18ZM5 8V19H19V8H5ZM5 21Q4.175 21 3.5875 20.4125T3 19V6.525Q3 6.175 3.1125 5.85T3.45 5.25L4.7 3.725Q4.975 3.375 5.3875 3.1875T6.25 3H17.75Q18.2 3 18.6125 3.1875T19.3 3.725L20.55 5.25Q20.775 5.525 20.8875 5.85T21 6.525V19Q21 19.825 20.4125 20.4125T19 21H5ZM5.4 6H18.6L17.75 5H6.25L5.4 6ZM12 13.5Z"), ARCHIVE_FILL("M12 18l4-4-1.4-1.4L13 14.2V10H11v4.2L9.4 12.6 8 14l4 4ZM5 21q-.825 0-1.4125-.5875T3 19V6.525q0-.35.1125-.675t.3375-.6L4.7 3.725q.275-.35.6875-.5375T6.25 3h11.5q.45 0 .8625.1875T19.3 3.725L20.55 5.25q.225.275.3375.6T21 6.525V19q0 .825-.5875 1.4125T19 21H5ZM5.4 6H18.6l-.85-1H6.25L5.4 6Z"), ARROW_BACK("M7.825 13 13.425 18.6 12 20 4 12 12 4 13.425 5.4 7.825 11H20V13H7.825Z"), ARROW_DROP_DOWN("M12 15 7 10H17L12 15Z"), ARROW_DROP_UP("M7 14 12 9 17 14H7Z"), ARROW_FORWARD("M16.175 13H4V11H16.175L10.575 5.4 12 4 20 12 12 20 10.575 18.6 16.175 13Z"), BETA_CIRCLE("M15,10.5C15,11.3 14.3,12 13.5,12C14.3,12 15,12.7 15,13.5V15A2,2 0 0,1 13,17H9V7H13A2,2 0 0,1 15,9V10.5M13,15V13H11V15H13M13,11V9H11V11H13M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material CANCEL("M8.4 17 12 13.4 15.6 17 17 15.6 13.4 12 17 8.4 15.6 7 12 10.6 8.4 7 7 8.4 10.6 12 7 15.6 8.4 17ZM12 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"), CHAT("M6 14H14V12H6V14ZM6 11H18V9H6V11ZM6 8H18V6H6V8ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"), CHECK("M9.55 18 3.85 12.3 5.275 10.875 9.55 15.15 18.725 5.975 20.15 7.4 9.55 18Z"), CHECKROOM("M3 20Q2.575 20 2.2875 19.7125T2 19Q2 18.75 2.1 18.5375T2.4 18.2L11 11.75V10Q11 9.575 11.3 9.2875T12.025 9Q12.65 9 13.075 8.55T13.5 7.475Q13.5 6.85 13.0625 6.425T12 6Q11.375 6 10.9375 6.4375T10.5 7.5H8.5Q8.5 6.05 9.525 5.025T12 4Q13.45 4 14.475 5.0125T15.5 7.475Q15.5 8.65 14.8125 9.575T13 10.85V11.75L21.6 18.2Q21.8 18.325 21.9 18.5375T22 19Q22 19.425 21.7125 19.7125T21 20H3ZM6 18H18L12 13.5 6 18Z"), CHECK_CIRCLE("M10.6 16.6 17.65 9.55 16.25 8.15 10.6 13.8 7.75 10.95 6.35 12.35 10.6 16.6ZM12 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"), CLOSE("M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"), CONTENT_CUT("M19 21l-7-7-2.35 2.35q.2.375.275.8T10 18q0 1.65-1.175 2.825T6 22q-1.65 0-2.825-1.175T2 18t1.175-2.825T6 14q.425 0 .85.075t.8.275L10 12 7.65 9.65q-.375.2-.8.275T6 10q-1.65 0-2.825-1.175T2 6q0-1.65 1.175-2.825T6 2q1.65 0 2.825 1.175T10 6q0 .425-.075.85t-.275.8L22 20v1H19Zm-4-10-2-2 6-6h3v1l-7 7ZM7.4125 7.4125Q8 6.825 8 6t-.5875-1.4125Q6.825 4 6 4t-1.4125.5875Q4 5.175 4 6t.5875 1.4125T6 8t1.4125-.5875ZM12.35 12.35q.15-.15.15-.35t-.15-.35-.35-.15-.35.15-.15.35.15.35.35.15.35-.15ZM7.4125 19.4125Q8 18.825 8 18t-.5875-1.4125Q6.825 16 6 16t-1.4125.5875Q4 17.175 4 18t.5875 1.4125Q5.175 20 6 20t1.4125-.5875Z"), CONTENT_COPY("M9 18Q8.175 18 7.5875 17.4125T7 16V4Q7 3.175 7.5875 2.5875T9 2H18Q18.825 2 19.4125 2.5875T20 4V16Q20 16.825 19.4125 17.4125T18 18H9ZM9 16H18V4H9V16ZM5 22Q4.175 22 3.5875 21.4125T3 20V6H5V20H16V22H5ZM9 16V4 16Z"), CONTENT_PASTE("M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v3H7V5H5zm7-14q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"), CREATE_NEW_FOLDER("M14 16h2V14h2V12H16V10H14v2H12v2h2v2ZM4 20q-.825 0-1.4125-.5875T2 18V6q0-.825.5875-1.4125T4 4h6l2 2h8q.825 0 1.4125.5875T22 8V18q0 .825-.5875 1.4125T20 20H4Zm0-2H20V8H11.175l-2-2H4V18ZV6 18Z"), DELETE("M7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM9 17H11V8H9V17ZM13 17H15V8H13V17ZM7 6V19 6Z"), DELETE_FOREVER("M9.4 16.5 12 13.9 14.6 16.5 16 15.1 13.4 12.5 16 9.9 14.6 8.5 12 11.1 9.4 8.5 8 9.9 10.6 12.5 8 15.1 9.4 16.5ZM7 21Q6.175 21 5.5875 20.4125T5 19V6H4V4H9V3H15V4H20V6H19V19Q19 19.825 18.4125 20.4125T17 21H7ZM17 6H7V19H17V6ZM7 6V19 6Z"), DEPLOYED_CODE("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM12 10.85 17.925 7.425 12 4 6.075 7.425 12 10.85ZM4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725L4 17.7ZM12 12Z"), DEPLOYED_CODE_FILL("M11 21.725 4 17.7q-.475-.275-.7375-.725T3 15.975V8.025q0-.55.2625-1T4 6.3l7-4.025Q11.475 2 12 2t1 .275L20 6.3q.475.275.7375.725t.2625 1v7.95q0 .55-.2625 1T20 17.7l-7 4.025Q12.525 22 12 22t-1-.275Zm0-9.15v6.85L12 20l1-.575v-6.85L19 9.1V8.05l-1.075-.625L12 10.85 6.075 7.425 5 8.05V9.1l6 3.475Z"), DOWNLOAD("M12 16 7 11 8.4 9.55 11 12.15V4H13V12.15L15.6 9.55 17 11 12 16ZM6 20Q5.175 20 4.5875 19.4125T4 18V15H6V18H18V15H20V18Q20 18.825 19.4125 19.4125T18 20H6Z"), DRESSER("M4 21V5Q4 4.175 4.5875 3.5875T6 3H18Q18.825 3 19.4125 3.5875T20 5V21H18V19H6V21H4ZM6 11H11V5H6V11ZM13 7H18V5H13V7ZM13 11H18V9H13V11ZM10 16H14V14H10V16ZM6 13V17H18V13H6ZM6 13V17 13Z"), EDIT("M5 19H6.425L16.2 9.225 14.775 7.8 5 17.575V19ZM3 21V16.75L16.2 3.575Q16.5 3.3 16.8625 3.15T17.625 3Q18.025 3 18.4 3.15T19.05 3.6L20.425 5Q20.725 5.275 20.8625 5.65T21 6.4Q21 6.8 20.8625 7.1625T20.425 7.825L7.25 21H3ZM19 6.4 17.6 5 19 6.4ZM15.475 8.525 14.775 7.8 16.2 9.225 15.475 8.525Z"), ERROR("M12 17Q12.425 17 12.7125 16.7125T13 16Q13 15.575 12.7125 15.2875T12 15Q11.575 15 11.2875 15.2875T11 16Q11 16.425 11.2875 16.7125T12 17ZM11 13H13V7H11V13ZM12 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"), EXPLORE("M12 12Zm0 8q-3.325 0-5.6625-2.3375T4 12Q4 8.675 6.3375 6.3375T12 4q3.325-0 5.6625 2.3375T20 12q0 3.325-2.3375 5.6625T12 20Zm0 2q2.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 22Zm0-8.5q.625 0 1.0625-.4375T13.5 12t-.4375-1.0625T12 10.5t-1.0625.4375T10.5 12t.4375 1.0625T12 13.5Zm-4.5 3 2-7 7-2-2 7-7 2Z"), EXTENSION("M8.8 21H5Q4.175 21 3.5875 20.4125T3 19V15.2Q4.2 15.2 5.1 14.4375T6 12.5Q6 11.325 5.1 10.5625T3 9.8V6Q3 5.175 3.5875 4.5875T5 4H9Q9 2.95 9.725 2.225T11.5 1.5Q12.55 1.5 13.275 2.225T14 4H18Q18.825 4 19.4125 4.5875T20 6V10Q21.05 10 21.775 10.725T22.5 12.5Q22.5 13.55 21.775 14.275T20 15V19Q20 19.825 19.4125 20.4125T18 21H14.2Q14.2 19.75 13.4125 18.875T11.5 18Q10.375 18 9.5875 18.875T8.8 21ZM5 19H7.125Q7.725 17.35 9.05 16.675T11.5 16Q12.625 16 13.95 16.675T15.875 19H18V13H20Q20.2 13 20.35 12.85T20.5 12.5Q20.5 12.3 20.35 12.15T20 12H18V6H12V4Q12 3.8 11.85 3.65T11.5 3.5Q11.3 3.5 11.15 3.65T11 4V6H5V8.2Q6.35 8.7 7.175 9.875T8 12.5Q8 13.925 7.175 15.1T5 16.8V19ZM11.5 12.5Z"), EXTENSION_FILL("M8.8 21H5q-.825 0-1.4125-.5875T3 19V15.2q1.2 0 2.1-.7625T6 12.5q0-1.175-.9-1.9375T3 9.8V6q0-.825.5875-1.4125T5 4H9q0-1.05.725-1.775T11.5 1.5q1.05 0 1.775.725T14 4h4q.825 0 1.4125.5875T20 6v4q1.05 0 1.775.725T22.5 12.5q0 1.05-.725 1.775T20 15v4q0 .825-.5875 1.4125T18 21H14.2q0-1.25-.7875-2.125T11.5 18q-1.125 0-1.9125.875T8.8 21Z"), FEEDBACK("M12 15Q12.425 15 12.7125 14.7125T13 14Q13 13.575 12.7125 13.2875T12 13Q11.575 13 11.2875 13.2875T11 14Q11 14.425 11.2875 14.7125T12 15ZM11 11H13V5H11V11ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"), FEEDBACK_FILL("M2 22V4q0-.825.5875-1.4125T4 2H20q.825 0 1.4125.5875T22 4V16q0 .825-.5875 1.4125T20 18H6L2 22Zm10-7q.425 0 .7125-.2875T13 14t-.2875-.7125T12 13t-.7125.2875T11 14t.2875.7125T12 15Zm-1-4h2V5H11v6Z"), FOLDER("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8V18Q22 18.825 21.4125 19.4125T20 20H4ZM4 18H20V8H11.175L9.175 6H4V18ZM4 18V6 18Z"), FOLDER_COPY("M3 21Q2.175 21 1.5875 20.4125T1 19V6H3V19H20V21H3ZM7 17Q6.175 17 5.5875 16.4125T5 15V4Q5 3.175 5.5875 2.5875T7 2H12L14 4H21Q21.825 4 22.4125 4.5875T23 6V15Q23 15.825 22.4125 16.4125T21 17H7ZM7 15H21V6H13.175L11.175 4H7V15ZM7 15V4 15Z"), FOLDER_OPEN("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8H11.175L9.175 6H4V18L6.4 10H23.5L20.925 18.575Q20.725 19.225 20.1875 19.6125T19 20H4ZM6.1 18H19L20.8 12H7.9L6.1 18ZM6.1 18 7.9 12 6.1 18ZM4 8V6 8Z"), FORMAT_LIST_BULLETED("M9 19V17H21V19H9ZM9 13V11H21V13H9ZM9 7V5H21V7H9ZM5 20Q4.175 20 3.5875 19.4125T3 18Q3 17.175 3.5875 16.5875T5 16Q5.825 16 6.4125 16.5875T7 18Q7 18.825 6.4125 19.4125T5 20ZM5 14Q4.175 14 3.5875 13.4125T3 12Q3 11.175 3.5875 10.5875T5 10Q5.825 10 6.4125 10.5875T7 12Q7 12.825 6.4125 13.4125T5 14ZM5 8Q4.175 8 3.5875 7.4125T3 6Q3 5.175 3.5875 4.5875T5 4Q5.825 4 6.4125 4.5875T7 6Q7 6.825 6.4125 7.4125T5 8Z"), FORT("M1 21V17l2-2V9L1 7V3H3V5H5V3H7V5H9V3h2V7L9 9v1h6V9L13 7V3h2V5h2V3h2V5h2V3h2V7L21 9v6l2 2v4H14V18q0-.825-.5875-1.4125T12 16q-.825 0-1.4125.5875T10 18v3H1Zm2-2H8V18q0-1.65 1.175-2.825T12 14q1.65 0 2.825 1.175T16 18v1h5V17.825l-2-2V8.175L20.175 7h-4.35L17 8.175V12H7V8.175L8.175 7H3.825L5 8.175v7.65l-2 2V19Zm9-6Z"), FOR_YOU("M12 12Q14.025 12 16.225 11.5875T20 10.5V20.5Q18.5 21.175 16.35 21.5875T12 22Q9.8 22 7.65 21.5875T4 20.5V10.5Q5.575 11.175 7.775 11.5875T12 12ZM18 19V13.25Q16.75 13.6 15.1125 13.8T12 14Q10.525 14 8.8875 13.8T6 13.25V19Q7.25 19.45 8.875 19.725T12 20Q13.5 20 15.125 19.725T18 19ZM12 2Q13.65 2 14.825 3.175T16 6Q16 7.65 14.825 8.825T12 10Q10.35 10 9.175 8.825T8 6Q8 4.35 9.175 3.175T12 2ZM12 8Q12.825 8 13.4125 7.4125T14 6Q14 5.175 13.4125 4.5875T12 4Q11.175 4 10.5875 4.5875T10 6Q10 6.825 10.5875 7.4125T12 8ZM12 6ZM12 16.625Z"), GAMEPAD("M12 7.65ZM16.35 12ZM7.65 12ZM12 16.35ZM12 10.5 9 7.5V2H15V7.5L12 10.5ZM16.5 15 13.5 12 16.5 9H22V15H16.5ZM2 15V9H7.5L10.5 12 7.5 15H2ZM9 22V16.5L12 13.5 15 16.5V22H9ZM12 7.65 13 6.65V4H11V6.65L12 7.65ZM4 13H6.65L7.65 12 6.65 11H4V13ZM11 20H13V17.35L12 16.35 11 17.35V20ZM17.35 13H20V11H17.35L16.35 12 17.35 13Z"), GLOBE_BOOK("M3.075 13Q3.05 12.75 3.0375 12.5T3.025 12Q3.025 10.125 3.725 8.4875T5.65 5.6375Q6.875 4.425 8.5 3.7125T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 12.25 20.9875 12.5T20.95 13H18.925Q18.975 12.75 18.9875 12.5T19 12Q19 11.75 18.9875 11.5T18.925 11H15.975Q16 11.25 16 11.5V12.5Q16 12.75 15.975 13H14V12.175Q14 11.875 13.9875 11.575T13.95 11H10.075Q10.05 11.275 10.0375 11.575T10.025 12.175V13H8.05Q8.025 12.75 8.025 12.5V11.5Q8.025 11.25 8.05 11H5.1Q5.05 11.25 5.0375 11.5T5.025 12Q5.025 12.25 5.0375 12.5T5.1 13H3.075ZM5.7 9H8.275Q8.475 7.925 8.775 7.0625T9.425 5.5Q8.225 5.95 7.25 6.8625T5.7 9ZM10.35 9H13.65Q13.4 7.925 13.025 6.9T12 5Q11.35 5.875 10.9625 6.9T10.35 9ZM15.75 9H18.325Q17.75 7.775 16.7625 6.8625T14.575 5.5Q14.925 6.25 15.2375 7.0875T15.75 9ZM11 21V20Q11 18.75 10.125 17.875T8 17H2V15H8Q9.2 15 10.2375 15.525T12 17Q12.725 16.05 13.7625 15.525T16 15H22V17H16Q14.75 17 13.875 17.875T13 20V21H11Z"), GRAPH2("M5 22q-1.25 0-2.125-.875T2 19q0-.975.5625-1.75T4 16.175V14q0-1.25.875-2.125T7 11h4V7.825q-.875-.3-1.4375-1.075T9 5q0-1.25.875-2.125T12 2t2.125.875T15 5q0 .975-.5625 1.75T13 7.825V11h4q1.25 0 2.125.875T20 14v2.175q.875.3 1.4375 1.075T22 19q0 1.25-.875 2.125T19 22t-2.125-.875T16 19q0-.975.5625-1.75T18 16.175V14q0-.425-.2875-.7125T17 13H13v3.175q.875.3 1.4375 1.075T15 19q0 1.25-.875 2.125T12 22t-2.125-.875T9 19q0-.975.5625-1.75T11 16.175V13H7q-.425 0-.7125.2875T6 14v2.175q.875.3 1.4375 1.075T8 19q0 1.25-.875 2.125T5 22Zm0-2q.425 0 .7125-.2875T6 19q0-.425-.2875-.7125T5 18q-.425 0-.7125.2875T4 19q0 .425.2875.7125T5 20Zm7 0q.425 0 .7125-.2875T13 19q0-.425-.2875-.7125T12 18q-.425 0-.7125.2875T11 19q0 .425.2875.7125T12 20Zm7 0q.425 0 .7125-.2875T20 19q0-.425-.2875-.7125T19 18q-.425 0-.7125.2875T18 19q0 .425.2875.7125T19 20ZM12 6q.425 0 .7125-.2875T13 5q0-.425-.2875-.7125T12 4q-.425 0-.7125.2875T11 5q0 .425.2875.7125T12 6Z"), HELP("M11.95 18Q12.475 18 12.8375 17.6375T13.2 16.75Q13.2 16.225 12.8375 15.8625T11.95 15.5Q11.425 15.5 11.0625 15.8625T10.7 16.75Q10.7 17.275 11.0625 17.6375T11.95 18ZM11.05 14.15H12.9Q12.9 13.325 13.0875 12.85T14.15 11.55Q14.8 10.9 15.175 10.3125T15.55 8.9Q15.55 7.5 14.525 6.75T12.1 6Q10.675 6 9.7875 6.75T8.55 8.55L10.2 9.2Q10.325 8.75 10.7625 8.225T12.1 7.7Q12.9 7.7 13.3 8.1375T13.7 9.1Q13.7 9.6 13.4 10.0375T12.65 10.85Q11.55 11.825 11.3 12.325T11.05 14.15ZM12 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"), HELP_FILL("M11.95 18q.525 0 .8875-.3625T13.2 16.75q0-.525-.3625-.8875T11.95 15.5q-.525 0-.8875.3625T10.7 16.75q0 .525.3625.8875T11.95 18Zm-.9-3.85H12.9q0-.825.1875-1.3t1.0625-1.3q.65-.65 1.025-1.2375T15.55 8.9q0-1.4-1.025-2.15T12.1 6q-1.425 0-2.3125.75T8.55 8.55l1.65.65q.125-.45.5625-.975T12.1 7.7q.8 0 1.2.4375t.4.9625q0 .5-.3.9375t-.75.8125q-1.1.975-1.35 1.475t-.25 1.825ZM12 22q-2.075 0-3.9-.7875T4.925 19.075q-1.35-1.35-2.1375-3.175T2 12q0-2.075.7875-3.9T4.925 4.925Q6.275 3.575 8.1 2.7875T12 2q2.075 0 3.9.7875T19.075 4.925q1.35 1.35 2.1375 3.175T22 12q0 2.075-.7875 3.9T19.075 19.075q-1.35 1.35-3.175 2.1375T12 22Z"), HOME("M6 19H9V13H15V19H18V10L12 5.5 6 10V19ZM4 21V9L12 3 20 9V21H13V15H11V21H4ZM12 12.25Z"), HOST("M4 21Q3.175 21 2.5875 20.4125T2 19V5Q2 4.175 2.5875 3.5875T4 3H9Q9.825 3 10.4125 3.5875T11 5V19Q11 19.825 10.4125 20.4125T9 21H4ZM15 21Q14.175 21 13.5875 20.4125T13 19V5Q13 4.175 13.5875 3.5875T15 3H20Q20.825 3 21.4125 3.5875T22 5V19Q22 19.825 21.4125 20.4125T20 21H15ZM4 19H9V5H4V19ZM15 19H20V5H15V19ZM5 15H8V13H5V15ZM16 15H19V13H16V15ZM5 12H8V10H5V12ZM16 12H19V10H16V12ZM5 9H8V7H5V9ZM16 9H19V7H16V9ZM4 19H9 4ZM15 19H20 15Z"), 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"), 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"), LIST("M7 9V7H21V9H7ZM7 13V11H21V13H7ZM7 17V15H21V17H7ZM4 9Q3.575 9 3.2875 8.7125T3 8Q3 7.575 3.2875 7.2875T4 7Q4.425 7 4.7125 7.2875T5 8Q5 8.425 4.7125 8.7125T4 9ZM4 13Q3.575 13 3.2875 12.7125T3 12Q3 11.575 3.2875 11.2875T4 11Q4.425 11 4.7125 11.2875T5 12Q5 12.425 4.7125 12.7125T4 13ZM4 17Q3.575 17 3.2875 16.7125T3 16Q3 15.575 3.2875 15.2875T4 15Q4.425 15 4.7125 15.2875T5 16Q5 16.425 4.7125 16.7125T4 17Z"), LISTS("M2 20V16H6V20H2ZM8 20V16H22V20H8ZM2 14V10H6V14H2ZM8 14V10H22V14H8ZM2 8V4H6V8H2ZM8 8V4H22V8H8Z"), LOCAL_CAFE("M4 21V19H20V21H4ZM8 17Q6.35 17 5.175 15.825T4 13V3H20Q20.825 3 21.4125 3.5875T22 5V8Q22 8.825 21.4125 9.4125T20 10H18V13Q18 14.65 16.825 15.825T14 17H8ZM8 15H14Q14.825 15 15.4125 14.4125T16 13V5H6V13Q6 13.825 6.5875 14.4125T8 15ZM18 8H20V5H18V8ZM8 15H6 16 8Z"), LOCAL_CAFE_FILL("M4 21V19H20v2H4Zm4-4q-1.65 0-2.825-1.175T4 13V3H20q.825 0 1.4125.5875T22 5V8q0 .825-.5875 1.4125T20 10H18v3q0 1.65-1.175 2.825T14 17H8ZM18 8h2V5H18V8Z"), LOCATION_CITY("M3 21V7H9V5l3-3 3 3v6h6V21H3Zm2-2H7V17H5v2Zm0-4H7V13H5v2Zm0-4H7V9H5v2Zm6 8h2V17H11v2Zm0-4h2V13H11v2Zm0-4h2V9H11v2Zm0-4h2V5H11V7Zm6 12h2V17H17v2Zm0-4h2V13H17v2Z"), MENU("M3 18V16H21V18H3ZM3 13V11H21V13H3ZM3 8V6H21V8H3Z"), MICROSOFT("M4 20H22v2H4V13H20v7h2V4H20v7H4V4h7V20h2V4h9V2H2V22H4"), // Not Material MINIMIZE_CENTER("M6 13v-2h12v2H6Z"), // Not Material MORE_HORIZ("M6 14Q5.175 14 4.5875 13.4125T4 12Q4 11.175 4.5875 10.5875T6 10Q6.825 10 7.4125 10.5875T8 12Q8 12.825 7.4125 13.4125T6 14ZM12 14Q11.175 14 10.5875 13.4125T10 12Q10 11.175 10.5875 10.5875T12 10Q12.825 10 13.4125 10.5875T14 12Q14 12.825 13.4125 13.4125T12 14ZM18 14Q17.175 14 16.5875 13.4125T16 12Q16 11.175 16.5875 10.5875T18 10Q18.825 10 19.4125 10.5875T20 12Q20 12.825 19.4125 13.4125T18 14Z"), MORE_VERT("M12 20Q11.175 20 10.5875 19.4125T10 18Q10 17.175 10.5875 16.5875T12 16Q12.825 16 13.4125 16.5875T14 18Q14 18.825 13.4125 19.4125T12 20ZM12 14Q11.175 14 10.5875 13.4125T10 12Q10 11.175 10.5875 10.5875T12 10Q12.825 10 13.4125 10.5875T14 12Q14 12.825 13.4125 13.4125T12 14ZM12 8Q11.175 8 10.5875 7.4125T10 6Q10 5.175 10.5875 4.5875T12 4Q12.825 4 13.4125 4.5875T14 6Q14 6.825 13.4125 7.4125T12 8Z"), OPEN_IN_NEW("M5 21Q4.175 21 3.5875 20.4125T3 19V5Q3 4.175 3.5875 3.5875T5 3H12V5H5V19H19V12H21V19Q21 19.825 20.4125 20.4125T19 21H5ZM9.7 15.7 8.3 14.3 17.6 5H14V3H21V10H19V6.4L9.7 15.7Z"), OUTPUT("M5 21Q4.175 21 3.5875 20.4125T3 19V5Q3 4.175 3.5875 3.5875T5 3H19Q19.825 3 20.4125 3.5875T21 5V7H19V5H5V19H19V17H21V19Q21 19.825 20.4125 20.4125T19 21H5ZM17 17 15.6 15.6 18.175 13H9V11H18.175L15.6 8.4 17 7 22 12 17 17Z"), PACKAGE("M10 9.75 12 8.75 14 9.75V5H10V9.75ZM7 17V15H12V17H7ZM5 21Q4.175 21 3.5875 20.4125T3 19V5Q3 4.175 3.5875 3.5875T5 3H19Q19.825 3 20.4125 3.5875T21 5V19Q21 19.825 20.4125 20.4125T19 21H5ZM5 5V19 5ZM5 19H19V5H16V13L12 11 8 13V5H5V19Z"), PACKAGE2("M11 19.425V12.575L5 9.1V15.95L11 19.425ZM13 19.425 19 15.95V9.1L13 12.575V19.425ZM11 21.725 4 17.7Q3.525 17.425 3.2625 16.975T3 15.975V8.025Q3 7.475 3.2625 7.025T4 6.3L11 2.275Q11.475 2 12 2T13 2.275L20 6.3Q20.475 6.575 20.7375 7.025T21 8.025V15.975Q21 16.525 20.7375 16.975T20 17.7L13 21.725Q12.525 22 12 22T11 21.725ZM16 8.525 17.925 7.425 12 4 10.05 5.125 16 8.525ZM12 10.85 13.95 9.725 8.025 6.3 6.075 7.425 12 10.85Z"), PACKAGE2_FILL("M11 21.725v-9.15L3 7.95v8.025q0 .55.2625 1T4 17.7l7 4.025Zm2 0L20 17.7q.475-.275.7375-.725t.2625-1V7.95l-8 4.625v9.15Zm3.975-13.75 2.95-1.725L13 2.275Q12.525 2 12 2t-1 .275L9.025 3.4l7.95 4.575ZM12 10.85l2.975-1.7L7.05 4.55l-3 1.725L12 10.85Z"), PERSON("M12 12Q10.35 12 9.175 10.825T8 8Q8 6.35 9.175 5.175T12 4Q13.65 4 14.825 5.175T16 8Q16 9.65 14.825 10.825T12 12ZM4 20V17.2Q4 16.35 4.4375 15.6375T5.6 14.55Q7.15 13.775 8.75 13.3875T12 13Q13.65 13 15.25 13.3875T18.4 14.55Q19.125 14.925 19.5625 15.6375T20 17.2V20H4ZM6 18H18V17.2Q18 16.925 17.8625 16.7T17.5 16.35Q16.15 15.675 14.775 15.3375T12 15Q10.6 15 9.225 15.3375T6.5 16.35Q6.275 16.475 6.1375 16.7T6 17.2V18ZM12 10Q12.825 10 13.4125 9.4125T14 8Q14 7.175 13.4125 6.5875T12 6Q11.175 6 10.5875 6.5875T10 8Q10 8.825 10.5875 9.4125T12 10ZM12 8ZM12 18Z"), PUBLIC("M12 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 22ZM11 19.95V18Q10.175 18 9.5875 17.4125T9 16V15L4.2 10.2Q4.125 10.65 4.0625 11.1T4 12Q4 15.025 5.9875 17.3T11 19.95ZM17.9 17.4Q18.925 16.275 19.4625 14.8875T20 12Q20 9.55 18.6375 7.525T15 4.6V5Q15 5.825 14.4125 6.4125T13 7H11V9Q11 9.425 10.7125 9.7125T10 10H8V12H14Q14.425 12 14.7125 12.2875T15 13V16H16Q16.65 16 17.175 16.3875T17.9 17.4Z"), REFRESH("M12 20Q8.65 20 6.325 17.675T4 12Q4 8.65 6.325 6.325T12 4Q13.725 4 15.3 4.7125T18 6.75V4H20V11H13V9H17.2Q16.4 7.6 15.0125 6.8T12 6Q9.5 6 7.75 7.75T6 12Q6 14.5 7.75 16.25T12 18Q13.925 18 15.475 16.9T17.65 14H19.75Q19.05 16.65 16.9 18.325T12 20Z"), REDO("M9.9 19q-2.425 0-4.163-1.575T4 13.5t1.738-3.925T9.9 8h6.3l-2.6-2.6L15 4l5 5l-5 5l-1.4-1.4l2.6-2.6H9.9q-1.575 0-2.738 1T6 13.5T7.163 16T9.9 17H17v2z"), RELEASE_CIRCLE("M9,7H13A2,2 0 0,1 15,9V11C15,11.84 14.5,12.55 13.76,12.85L15,17H13L11.8,13H11V17H9V7M11,9V11H13V9H11M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M12,4A8,8 0 0,0 4,12C4,16.41 7.58,20 12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4Z"), // Not Material RESTORE("M12 21Q8.55 21 5.9875 18.7125T3.05 13H5.1Q5.45 15.6 7.4125 17.3T12 19Q14.925 19 16.9625 16.9625T19 12Q19 9.075 16.9625 7.0375T12 5Q10.275 5 8.775 5.8T6.25 8H9V10H3V4H5V6.35Q6.275 4.75 8.1125 3.875T12 3Q13.875 3 15.5125 3.7125T18.3625 5.6375Q19.575 6.85 20.2875 8.4875T21 12Q21 13.875 20.2875 15.5125T18.3625 18.3625Q17.15 19.575 15.5125 20.2875T12 21Z"), // Not Material ROCKET_LAUNCH("M5.65 10.025 7.6 10.85Q7.95 10.15 8.325 9.5T9.15 8.2L7.75 7.925 5.65 10.025ZM9.2 12.1 12.05 14.925Q13.1 14.525 14.3 13.7T16.55 11.825Q18.3 10.075 19.2875 7.9375T20.15 4Q18.35 3.875 16.2 4.8625T12.3 7.6Q11.25 8.65 10.425 9.85T9.2 12.1ZM13.65 10.475Q13.075 9.9 13.075 9.0625T13.65 7.65Q14.225 7.075 15.075 7.075T16.5 7.65Q17.075 8.225 17.075 9.0625T16.5 10.475Q15.925 11.05 15.075 11.05T13.65 10.475ZM14.125 18.5 16.225 16.4 15.95 15Q15.3 15.45 14.65 15.8125T13.3 16.525L14.125 18.5ZM21.95 2.175Q22.425 5.2 21.3625 8.0625T17.7 13.525L18.2 16Q18.3 16.5 18.15 16.975T17.65 17.8L13.45 22 11.35 17.075 7.075 12.8 2.15 10.7 6.325 6.5Q6.675 6.15 7.1625 6T8.15 5.95L10.625 6.45Q13.225 3.85 16.075 2.775T21.95 2.175ZM3.925 15.975Q4.8 15.1 6.0625 15.0875T8.2 15.95Q9.075 16.825 9.0625 18.0875T8.175 20.225Q7.55 20.85 6.0875 21.3T2.05 22.1Q2.4 19.525 2.85 18.0625T3.925 15.975ZM5.35 17.375Q5.1 17.625 4.85 18.2875T4.5 19.625Q5.175 19.525 5.8375 19.2875T6.75 18.8Q7.05 18.5 7.075 18.075T6.8 17.35Q6.5 17.05 6.075 17.0625T5.35 17.375Z"), SCHEMA("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Zm2-2H9V19H6v2Zm0-8H9V11H6v2Zm10 0h3V11H16v2ZM6 5H9V3H6V5ZM7.5 4Zm0 8Zm10 0Zm-10 8Z"), SCHEMA_FILL("M4 23V17H6.5V15H4V9H6.5V7H4V1h7V7H8.5V9H11v2h3V9h7v6H14V13H11v2H8.5v2H11v6H4Z"), SCREENSHOT_MONITOR("M15 16H19V12H17.5V14.5H15V16ZM5 10H6.5V7.5H9V6H5V10ZM8 21V19H4Q3.175 19 2.5875 18.4125T2 17V5Q2 4.175 2.5875 3.5875T4 3H20Q20.825 3 21.4125 3.5875T22 5V17Q22 17.825 21.4125 18.4125T20 19H16V21H8ZM4 17H20V5H4V17ZM4 17V5 17Z"), SCRIPT("M14,20A2,2 0 0,0 16,18V5H9A1,1 0 0,0 8,6V16H5V5A3,3 0 0,1 8,2H19A3,3 0 0,1 22,5V6H18V18L18,19A3,3 0 0,1 15,22H5A3,3 0 0,1 2,19V18H12A2,2 0 0,0 14,20Z"), // Not Material SEARCH("M19.6 21 13.3 14.7Q12.55 15.3 11.575 15.65T9.5 16Q6.775 16 4.8875 14.1125T3 9.5Q3 6.775 4.8875 4.8875T9.5 3Q12.225 3 14.1125 4.8875T16 9.5Q16 10.6 15.65 11.575T14.7 13.3L21 19.6 19.6 21ZM9.5 14Q11.375 14 12.6875 12.6875T14 9.5Q14 7.625 12.6875 6.3125T9.5 5Q7.625 5 6.3125 6.3125T5 9.5Q5 11.375 6.3125 12.6875T9.5 14Z"), SELECT_ALL("M7 17V7H17V17H7ZM9 15H15V9H9V15ZM5 19V21Q4.175 21 3.5875 20.4125T3 19H5ZM3 17V15H5V17H3ZM3 13V11H5V13H3ZM3 9V7H5V9H3ZM5 5H3Q3 4.175 3.5875 3.5875T5 3V5ZM7 21V19H9V21H7ZM7 5V3H9V5H7ZM11 21V19H13V21H11ZM11 5V3H13V5H11ZM15 21V19H17V21H15ZM15 5V3H17V5H15ZM19 21V19H21Q21 19.825 20.4125 20.4125T19 21ZM19 17V15H21V17H19ZM19 13V11H21V13H19ZM19 9V7H21V9H19ZM19 5V3Q19.825 3 20.4125 3.5875T21 5H19Z"), SETTINGS("M19.43 12.98C19.47 12.66 19.5 12.34 19.5 12 19.5 11.66 19.47 11.34 19.43 11.02L21.54 9.37C21.73 9.22 21.78 8.95 21.66 8.73L19.66 5.27C19.57 5.11 19.4 5.02 19.22 5.02 19.16 5.02 19.1 5.03 19.05 5.05L16.56 6.05C16.04 5.65 15.48 5.32 14.87 5.07L14.49 2.42C14.46 2.18 14.25 2 14 2H10C9.75 2 9.54 2.18 9.51 2.42L9.13 5.07C8.52 5.32 7.96 5.66 7.44 6.05L4.95 5.05C4.89 5.03 4.83 5.02 4.77 5.02 4.6 5.02 4.43 5.11 4.34 5.27L2.34 8.73C2.21 8.95 2.27 9.22 2.46 9.37L4.57 11.02C4.53 11.34 4.5 11.67 4.5 12 4.5 12.33 4.53 12.66 4.57 12.98L2.46 14.63C2.27 14.78 2.22 15.05 2.34 15.27L4.34 18.73C4.43 18.89 4.6 18.98 4.78 18.98 4.84 18.98 4.9 18.97 4.95 18.95L7.44 17.95C7.96 18.35 8.52 18.68 9.13 18.93L9.51 21.58C9.54 21.82 9.75 22 10 22H14C14.25 22 14.46 21.82 14.49 21.58L14.87 18.93C15.48 18.68 16.04 18.34 16.56 17.95L19.05 18.95C19.11 18.97 19.17 18.98 19.23 18.98 19.4 18.98 19.57 18.89 19.66 18.73L21.66 15.27C21.78 15.05 21.73 14.78 21.54 14.63L19.43 12.98ZM17.45 11.27C17.49 11.58 17.5 11.79 17.5 12 17.5 12.21 17.48 12.43 17.45 12.73L17.31 13.86 18.2 14.56 19.28 15.4 18.58 16.61 17.31 16.1 16.27 15.68 15.37 16.36C14.94 16.68 14.53 16.92 14.12 17.09L13.06 17.52 12.9 18.65 12.7 20H11.3L11.11 18.65 10.95 17.52 9.89 17.09C9.46 16.91 9.06 16.68 8.66 16.38L7.75 15.68 6.69 16.11 5.42 16.62 4.72 15.41 5.8 14.57 6.69 13.87 6.55 12.74C6.52 12.43 6.5 12.2 6.5 12S6.52 11.57 6.55 11.27L6.69 10.14 5.8 9.44 4.72 8.6 5.42 7.39 6.69 7.9 7.73 8.32 8.63 7.64C9.06 7.32 9.47 7.08 9.88 6.91L10.94 6.48 11.1 5.35 11.3 4H12.69L12.88 5.35 13.04 6.48 14.1 6.91C14.53 7.09 14.93 7.32 15.33 7.62L16.24 8.32 17.3 7.89 18.57 7.38 19.27 8.59 18.2 9.44 17.31 10.14 17.45 11.27ZM12 8C9.79 8 8 9.79 8 12S9.79 16 12 16 16 14.21 16 12 14.21 8 12 8ZM12 14C10.9 14 10 13.1 10 12S10.9 10 12 10 14 10.9 14 12 13.1 14 12 14Z"), // Material Icons SETTINGS_FILL("M9.25 22l-.4-3.2q-.325-.125-.6125-.3t-.5625-.375L4.7 19.375l-2.75-4.75 2.575-1.95Q4.5 12.5 4.5 12.3375v-.675q0-.1625.025-.3375L1.95 9.375 4.7 4.625l2.975 1.25q.275-.2.575-.375t.6-.3L9.25 2h5.5l.4 3.2q.325.125.6125.3t.5625.375L19.3 4.625l2.75 4.75-2.575 1.95q.025.175.025.3375v.675q0 .1625-.05.3375l2.575 1.95-2.75 4.75-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2H9.25Zm2.8-6.5q1.45 0 2.475-1.025T15.55 12 14.525 9.525 12.05 8.5q-1.475 0-2.4875 1.025T8.55 12q0 1.45 1.0125 2.475T12.05 15.5Z"), // Material Icons STADIA_CONTROLLER("M4.725 20Q3.225 20 2.1625 18.925T1.05 16.325Q1.05 16.1 1.075 15.875T1.15 15.425L3.25 7.025Q3.6 5.675 4.675 4.8375T7.125 4H16.875Q18.25 4 19.325 4.8375T20.75 7.025L22.85 15.425Q22.9 15.65 22.9375 15.8875T22.975 16.35Q22.975 17.875 21.8875 18.9375T19.275 20Q18.225 20 17.325 19.45T15.975 17.95L15.275 16.5Q15.15 16.25 14.9 16.125T14.375 16H9.625Q9.35 16 9.1 16.125T8.725 16.5L8.025 17.95Q7.575 18.9 6.675 19.45T4.725 20ZM4.8 18Q5.275 18 5.6625 17.75T6.25 17.075L6.95 15.65Q7.325 14.875 8.05 14.4375T9.625 14H14.375Q15.225 14 15.95 14.45T17.075 15.65L17.775 17.075Q17.975 17.5 18.3625 17.75T19.225 18Q19.925 18 20.425 17.5375T20.95 16.375Q20.95 16.4 20.9 15.9L18.8 7.525Q18.625 6.85 18.1 6.425T16.875 6H7.125Q6.425 6 5.8875 6.425T5.2 7.525L3.1 15.9Q3.05 16.05 3.05 16.35 3.05 17.05 3.5625 17.525T4.8 18ZM13.5 11Q13.925 11 14.2125 10.7125T14.5 10Q14.5 9.575 14.2125 9.2875T13.5 9Q13.075 9 12.7875 9.2875T12.5 10Q12.5 10.425 12.7875 10.7125T13.5 11ZM15.5 9Q15.925 9 16.2125 8.7125T16.5 8Q16.5 7.575 16.2125 7.2875T15.5 7Q15.075 7 14.7875 7.2875T14.5 8Q14.5 8.425 14.7875 8.7125T15.5 9ZM15.5 13Q15.925 13 16.2125 12.7125T16.5 12Q16.5 11.575 16.2125 11.2875T15.5 11Q15.075 11 14.7875 11.2875T14.5 12Q14.5 12.425 14.7875 12.7125T15.5 13ZM17.5 11Q17.925 11 18.2125 10.7125T18.5 10Q18.5 9.575 18.2125 9.2875T17.5 9Q17.075 9 16.7875 9.2875T16.5 10Q16.5 10.425 16.7875 10.7125T17.5 11ZM8.5 12.5Q8.825 12.5 9.0375 12.2875T9.25 11.75V10.75H10.25Q10.575 10.75 10.7875 10.5375T11 10Q11 9.675 10.7875 9.4625T10.25 9.25H9.25V8.25Q9.25 7.925 9.0375 7.7125T8.5 7.5Q8.175 7.5 7.9625 7.7125T7.75 8.25V9.25H6.75Q6.425 9.25 6.2125 9.4625T6 10Q6 10.325 6.2125 10.5375T6.75 10.75H7.75V11.75Q7.75 12.075 7.9625 12.2875T8.5 12.5ZM12 12Z"), STADIA_CONTROLLER_FILL("M4.725 20q-1.5 0-2.5625-1.075T1.05 16.325q0-.225.025-.45t.075-.45l2.1-8.4q.35-1.35 1.425-2.1875T7.125 4h9.75q1.375 0 2.45.8375T20.75 7.025l2.1 8.4q.05.225.0875.4625t.0375.4625q0 1.525-1.0875 2.5875T19.275 20q-1.05 0-1.95-.55t-1.35-1.5l-.7-1.45q-.125-.25-.375-.375T14.375 16H9.625q-.275 0-.525.125t-.375.375l-.7 1.45q-.45.95-1.35 1.5T4.725 20ZM13.5 11q.425 0 .7125-.2875T14.5 10t-.2875-.7125T13.5 9t-.7125.2875T12.5 10t.2875.7125T13.5 11Zm2-2q.425 0 .7125-.2875T16.5 8q0-.425-.2875-.7125T15.5 7q-.425 0-.7125.2875T14.5 8t.2875.7125T15.5 9Zm0 4q.425 0 .7125-.2875T16.5 12q0-.425-.2875-.7125T15.5 11q-.425 0-.7125.2875T14.5 12t.2875.7125T15.5 13Zm2-2q.425 0 .7125-.2875T18.5 10q0-.425-.2875-.7125T17.5 9q-.425 0-.7125.2875T16.5 10q0 .425.2875.7125T17.5 11Zm-9 1.5q.325 0 .5375-.2125T9.25 11.75v-1h1q.325 0 .5375-.2125T11 10t-.2125-.5375T10.25 9.25h-1v-1q0-.325-.2125-.5375T8.5 7.5q-.325 0-.5375.2125T7.75 8.25v1h-1q-.325 0-.5375.2125T6 10q0 .325.2125.5375T6.75 10.75h1v1q0 .325.2125.5375T8.5 12.5Z"), STYLE("M3.975 19.8 3.125 19.45Q2.35 19.125 2.0875 18.325T2.175 16.75L3.975 12.85V19.8ZM7.975 22Q7.15 22 6.5625 21.4125T5.975 20V14L8.625 21.35Q8.7 21.525 8.775 21.6875T8.975 22H7.975ZM13.125 21.9Q12.325 22.2 11.575 21.825T10.525 20.65L6.075 8.45Q5.775 7.65 6.125 6.8875T7.275 5.85L14.825 3.1Q15.625 2.8 16.375 3.175T17.425 4.35L21.875 16.55Q22.175 17.35 21.825 18.1125T20.675 19.15L13.125 21.9ZM10.975 10Q11.4 10 11.6875 9.7125T11.975 9Q11.975 8.575 11.6875 8.2875T10.975 8Q10.55 8 10.2625 8.2875T9.975 9Q9.975 9.425 10.2625 9.7125T10.975 10ZM12.425 20 19.975 17.25 15.525 5 7.975 7.75 12.425 20ZM7.975 7.75 15.525 5 7.975 7.75Z"), STYLE_FILL("M3.975 19.8l-.85-.35q-.775-.325-1.0375-1.125T2.175 16.75l1.8-3.9V19.8Zm4 2.2q-.825 0-1.4125-.5875T5.975 20V14l2.65 7.35q.075.175.15.3375t.2.3125h-1Zm5.15-.1q-.8.3-1.55-.075t-1.05-1.175L6.075 8.45q-.3-.8.05-1.5625T7.275 5.85l7.55-2.75q.8-.3 1.55.075t1.05 1.175l4.45 12.2q.3.8-.05 1.5625T20.675 19.15l-7.55 2.75ZM10.975 10q.425 0 .7125-.2875T11.975 9q0-.425-.2875-.7125T10.975 8t-.7125.2875T9.975 9t.2875.7125T10.975 10Z"), TEXTURE("M4.4 21q-.475-.1-.8875-.5125T3 19.6L19.6 3q.525.125.9.5125t.525.8875L4.4 21ZM3 14.7v-2.8L11.9 3h2.8L3 14.7ZM3 7V5q0-.825.5875-1.4125T5 3h2L3 7Zm14 14 4-4v2q0 .825-.5875 1.4125T19 21h-2Zm-7.7 0L21 9.3v2.8L12.1 21H9.3Z"), TRIP("M4 21Q3.175 21 2.5875 20.4125T2 19V8Q2 7.175 2.5875 6.5875T4 6H8V4Q8 3.175 8.5875 2.5875T10 2H14Q14.825 2 15.4125 2.5875T16 4V6H20Q20.825 6 21.4125 6.5875T22 8V19Q22 19.825 21.4125 20.4125T20 21H4ZM10 6H14V4H10V6ZM6 8H4V19H6V8ZM16 19V8H8V19H16ZM18 8V19H20V8H18ZM12 13.5Z"), TUNE("M11 21V15H13V17H21V19H13V21H11ZM3 19V17H9V19H3ZM7 15V13H3V11H7V9H9V15H7ZM11 13V11H21V13H11ZM15 9V3H17V5H21V7H17V9H15ZM3 7V5H13V7H3Z"), UNFOLD_MORE("M12 21 7.5 16.5l1.45-1.45L12 18.1l3.05-3.05 1.45 1.45L12 21ZM8.95 9.05 7.5 7.6 12 3.1l4.5 4.5-1.45 1.45L12 6 8.95 9.05Z"), UPDATE("M12 21Q10.125 21 8.4875 20.2875T5.6375 18.3625Q4.425 17.15 3.7125 15.5125T3 12Q3 10.125 3.7125 8.4875T5.6375 5.6375Q6.85 4.425 8.4875 3.7125T12 3Q14.05 3 15.8875 3.875T19 6.35V4H21V10H15V8H17.75Q16.725 6.6 15.225 5.8T12 5Q9.075 5 7.0375 7.0375T5 12Q5 14.925 7.0375 16.9625T12 19Q14.625 19 16.5875 17.3T18.9 13H20.95Q20.575 16.425 18.0125 18.7125T12 21ZM14.8 16.2 11 12.4V7H13V11.6L16.2 14.8 14.8 16.2Z"), UNDO("M7 19v-2h7.1q1.575 0 2.738-1T18 13.5T16.838 11T14.1 10H7.8l2.6 2.6L9 14L4 9l5-5l1.4 1.4L7.8 8h6.3q2.425 0 4.163 1.575T20 13.5t-1.737 3.925T14.1 19z"), VISIBILITY("M12 16q1.875 0 3.1875-1.3125T16.5 11.5 15.1875 8.3125 12 7 8.8125 8.3125 7.5 11.5t1.3125 3.1875T12 16Zm0-1.8q-1.125 0-1.9125-.7875T9.3 11.5t.7875-1.9125T12 8.8q1.125 0 1.9125.7875T14.7 11.5q0 1.125-.7875 1.9125T12 14.2ZM12 19q-3.65 0-6.65-2.0375T1 11.5Q2.35 8.075 5.35 6.0375T12 4q3.65 0 6.65 2.0375T23 11.5q-1.35 3.425-4.35 5.4625T12 19Zm0-7.5ZM12 17q2.825 0 5.1875-1.4875T20.8 11.5q-1.25-2.525-3.6125-4.0125T12 6 6.8125 7.4875 3.2 11.5q1.25 2.525 3.6125 4.0125T12 17Z"), VISIBILITY_OFF("M16.1 13.3l-1.45-1.45q.225-1.175-.675-2.2t-2.325-.8L10.2 7.4q.425-.2.8625-.3T12 7q1.875 0 3.1875 1.3125T16.5 11.5q0 .5-.1.9375t-.3.8625Zm3.2 3.15-1.45-1.4q.95-.725 1.6875-1.5875T20.8 11.5q-1.25-2.525-3.5875-4.0125T12 6q-.725 0-1.425.1T9.2 6.4L7.65 4.85q1.025-.425 2.1-.6375T12 4q3.775 0 6.725 2.0875T23 11.5q-.575 1.475-1.5125 2.7375T19.3 16.45Zm.5 6.15-4.2-4.15q-.875.275-1.7625.4125T12 19q-3.775 0-6.725-2.0875T1 11.5q.525-1.325 1.325-2.4625T4.15 7L1.4 4.2 2.8 2.8 21.2 21.2l-1.4 1.4ZM5.55 8.4q-.725.65-1.325 1.425T3.2 11.5q1.25 2.525 3.5875 4.0125T12 17q.5 0 .975-.0625T13.95 16.8l-.9-.95q-.275.075-.525.1125T12 16q-1.875 0-3.1875-1.3125T7.5 11.5q0-.275.0375-.525T7.65 10.45L5.55 8.4Zm7.975 2.325ZM9.75 12.6Z"), WARNING("M1 21 12 2 23 21H1ZM4.45 19H19.55L12 6 4.45 19ZM12 18Q12.425 18 12.7125 17.7125T13 17Q13 16.575 12.7125 16.2875T12 16Q11.575 16 11.2875 16.2875T11 17Q11 17.425 11.2875 17.7125T12 18ZM11 15H13V10H11V15ZM12 12.5Z"), WB_SUNNY("M11 4V1H13V4H11ZM11 23V20H13V23H11ZM20 13V11H23V13H20ZM1 13V11H4V13H1ZM18.7 6.7 17.3 5.3 19.05 3.5 20.5 4.95 18.7 6.7ZM4.95 20.5 3.5 19.05 5.3 17.3 6.7 18.7 4.95 20.5ZM19.05 20.5 17.3 18.7 18.7 17.3 20.5 19.05 19.05 20.5ZM5.3 6.7 3.5 4.95 4.95 3.5 6.7 5.3 5.3 6.7ZM12 18Q9.5 18 7.75 16.25T6 12Q6 9.5 7.75 7.75T12 6Q14.5 6 16.25 7.75T18 12Q18 14.5 16.25 16.25T12 18ZM12 16Q13.675 16 14.8375 14.8375T16 12Q16 10.325 14.8375 9.1625T12 8Q10.325 8 9.1625 9.1625T8 12Q8 13.675 9.1625 14.8375T12 16ZM12 12Z"), WB_SUNNY_FILL("M11 4V1h2V4H11Zm0 19V20h2v3H11Zm9-10V11h3v2H20ZM1 13V11H4v2H1ZM18.7 6.7 17.3 5.3l1.75-1.8L20.5 4.95 18.7 6.7ZM4.95 20.5 3.5 19.05 5.3 17.3l1.4 1.4-1.75 1.8Zm14.1 0-1.75-1.8 1.4-1.4 1.8 1.75-1.45 1.45ZM5.3 6.7 3.5 4.95 4.95 3.5 6.7 5.3 5.3 6.7ZM12 18q-2.5 0-4.25-1.75T6 12 7.75 7.75 12 6t4.25 1.75T18 12t-1.75 4.25T12 18Z"), ; public static final double DEFAULT_SIZE = 24; static void setSize(SVGPath path, double size) { double scale = size / DEFAULT_SIZE; path.setScaleX(scale); path.setScaleY(scale); } private final String rawPath; private String path; SVG(String rawPath) { this.rawPath = rawPath; } public String getPath() { if (path == null) // We move the current point so that SVGPath will treat 0 0 24 24 as the layout bounds path = "M24 24ZM0 0Z" + rawPath; return path; } public SVGPath createIcon() { var path = new SVGPath(); path.getStyleClass().add("svg"); path.setContent(getPath()); return path; } public SVGContainer createIcon(double size) { return new SVGContainer(this, size); } public SVGPath createIcon(ObservableValue color) { SVGPath path = createIcon(); path.fillProperty().bind(color); return path; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/SVGContainer.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.scene.Parent; import javafx.scene.shape.SVGPath; import javafx.util.Duration; import org.jackhuang.hmcl.ui.animation.Motion; /// A lightweight wrapper for displaying [SVG] icons. /// /// @author Glavo public final class SVGContainer extends Parent { private static final String DEFAULT_STYLE_CLASS = "svg-container"; private final SVGPath path = new SVGPath(); private SVG icon = SVG.NONE; private double iconSize = SVG.DEFAULT_SIZE; private SVGPath tempPath; private Timeline timeline; { this.getStyleClass().add(DEFAULT_STYLE_CLASS); this.path.getStyleClass().add("svg"); } /// Creates an SVGContainer with the default icon and the default icon size. public SVGContainer() { this(SVG.NONE, SVG.DEFAULT_SIZE); } /// Creates an SVGContainer showing the given icon using the default icon size. /// /// @param icon the [SVG] icon to display public SVGContainer(SVG icon) { this(icon, SVG.DEFAULT_SIZE); } /// Creates an SVGContainer with a custom icon size. The initial icon is /// [SVG#NONE]. /// /// @param iconSize the icon size public SVGContainer(double iconSize) { this(SVG.NONE, iconSize); } /// Creates an SVGContainer with the specified icon and size. /// /// @param icon the [SVG] icon to display /// @param iconSize the icon size public SVGContainer(SVG icon, double iconSize) { setIconSizeImpl(iconSize); setIcon(icon); } public double getIconSize() { return iconSize; } private void setIconSizeImpl(double newSize) { this.iconSize = newSize; SVG.setSize(path, newSize); if (tempPath != null) SVG.setSize(tempPath, newSize); } public void setIconSize(double newSize) { setIconSizeImpl(newSize); requestLayout(); } /// Gets the currently displayed icon. public SVG getIcon() { return icon; } /// Sets the icon to display without animation. public void setIcon(SVG newIcon) { setIcon(newIcon, Duration.ZERO); } /// Sets the icon to display with a cross-fade animation. public void setIcon(SVG newIcon, Duration animationDuration) { if (timeline != null) { timeline.stop(); timeline = null; } SVG oldIcon = this.icon; this.icon = newIcon; if (animationDuration.equals(Duration.ZERO)) { path.setContent(newIcon.getPath()); path.setOpacity(1); if (getChildren().size() != 1) getChildren().setAll(path); } else { if (tempPath == null) { tempPath = new SVGPath(); tempPath.getStyleClass().add("svg"); SVG.setSize(tempPath, iconSize); } else tempPath.setOpacity(1); tempPath.setContent(oldIcon.getPath()); getChildren().setAll(path, tempPath); path.setOpacity(0); path.setContent(newIcon.getPath()); timeline = new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(path.opacityProperty(), 0, Motion.LINEAR), new KeyValue(tempPath.opacityProperty(), 1, Motion.LINEAR) ), new KeyFrame(animationDuration, new KeyValue(path.opacityProperty(), 1, Motion.LINEAR), new KeyValue(tempPath.opacityProperty(), 0, Motion.LINEAR) ) ); timeline.setOnFinished(e -> { getChildren().setAll(path); timeline = null; }); timeline.play(); } } // Parent @Override public double prefWidth(double height) { return iconSize; } @Override public double prefHeight(double width) { return iconSize; } @Override public double minHeight(double width) { return iconSize; } @Override public double minWidth(double height) { return iconSize; } @Override protected void layoutChildren() { // Do nothing } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/ScrollUtils.java ================================================ // Copy from https://github.com/palexdev/MaterialFX/blob/c8038ce2090f5cddf923a19d79cc601db86a4d17/materialfx/src/main/java/io/github/palexdev/materialfx/utils/ScrollUtils.java /* * Copyright (C) 2022 Parisi Alessandro * This file is part of MaterialFX (https://github.com/palexdev/MaterialFX). * * MaterialFX is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * MaterialFX 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 Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with MaterialFX. If not, see . */ package org.jackhuang.hmcl.ui; import javafx.animation.Animation; import javafx.animation.Animation.Status; import javafx.animation.KeyFrame; import javafx.animation.Timeline; import javafx.event.EventHandler; import javafx.scene.control.ScrollPane; import javafx.scene.control.skin.VirtualFlow; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; import javafx.util.Duration; import org.jackhuang.hmcl.util.Holder; /** * Utility class for ScrollPanes. */ final class ScrollUtils { public enum ScrollDirection { UP(-1), RIGHT(-1), DOWN(1), LEFT(1); final int intDirection; ScrollDirection(int intDirection) { this.intDirection = intDirection; } public int intDirection() { return intDirection; } } private static final double DEFAULT_SPEED = 1.0; private static final double DEFAULT_TRACK_PAD_ADJUSTMENT = 7.0; private static final double CUTOFF_DELTA = 0.01; /** * Determines if the given ScrollEvent comes from a trackpad. *

* Although this method works in most cases, it is not very accurate. * Since in JavaFX there's no way to tell if a ScrollEvent comes from a trackpad or a mouse * we use this trick: I noticed that a mouse scroll has a delta of 32 (don't know if it changes depending on the device or OS) * and trackpad scrolls have a way smaller delta. So depending on the scroll direction we check if the delta is lesser than 10 * (trackpad event) or greater(mouse event). * * @see ScrollEvent#getDeltaX() * @see ScrollEvent#getDeltaY() */ public static boolean isTrackPad(ScrollEvent event, ScrollDirection scrollDirection) { return switch (scrollDirection) { case UP, DOWN -> Math.abs(event.getDeltaY()) < 10; case LEFT, RIGHT -> Math.abs(event.getDeltaX()) < 10; }; } /** * Determines the scroll direction of the given ScrollEvent. *

* Although this method works fine, it is not very accurate. * In JavaFX there's no concept of scroll direction, if you try to scroll with a trackpad * you'll notice that you can scroll in both directions at the same time, both deltaX and deltaY won't be 0. *

* For this method to work we assume that this behavior is not possible. *

* If deltaY is 0 we return LEFT or RIGHT depending on deltaX (respectively if lesser or greater than 0). *

* Else we return DOWN or UP depending on deltaY (respectively if lesser or greater than 0). * * @see ScrollEvent#getDeltaX() * @see ScrollEvent#getDeltaY() */ public static ScrollDirection determineScrollDirection(ScrollEvent event) { double deltaX = event.getDeltaX(); double deltaY = event.getDeltaY(); if (deltaY == 0.0) { return deltaX < 0 ? ScrollDirection.LEFT : ScrollDirection.RIGHT; } else { return deltaY < 0 ? ScrollDirection.DOWN : ScrollDirection.UP; } } //================================================================================ // ScrollPanes //================================================================================ /** * Adds a smooth scrolling effect to the given scroll pane, * calls {@link #addSmoothScrolling(ScrollPane, double)} with a * default speed value of 1. */ public static void addSmoothScrolling(ScrollPane scrollPane) { addSmoothScrolling(scrollPane, DEFAULT_SPEED); } /** * Adds a smooth scrolling effect to the given scroll pane with the given scroll speed. * Calls {@link #addSmoothScrolling(ScrollPane, double, double)} * with a default trackPadAdjustment of 7. */ public static void addSmoothScrolling(ScrollPane scrollPane, double speed) { addSmoothScrolling(scrollPane, speed, DEFAULT_TRACK_PAD_ADJUSTMENT); } /** * Adds a smooth scrolling effect to the given scroll pane with the given * scroll speed and the given trackPadAdjustment. *

* The trackPadAdjustment is a value used to slow down the scrolling if a trackpad is used. * This is kind of a workaround and it's not perfect, but at least it's way better than before. * The default value is 7, tested up to 10, further values can cause scrolling misbehavior. */ public static void addSmoothScrolling(ScrollPane scrollPane, double speed, double trackPadAdjustment) { smoothScroll(scrollPane, speed, trackPadAdjustment); } /// @author Glavo public static void addSmoothScrolling(VirtualFlow virtualFlow) { addSmoothScrolling(virtualFlow, DEFAULT_SPEED); } /// @author Glavo public static void addSmoothScrolling(VirtualFlow virtualFlow, double speed) { addSmoothScrolling(virtualFlow, speed, DEFAULT_TRACK_PAD_ADJUSTMENT); } /// @author Glavo public static void addSmoothScrolling(VirtualFlow virtualFlow, double speed, double trackPadAdjustment) { smoothScroll(virtualFlow, speed, trackPadAdjustment); } private static final double[] FRICTIONS = {0.99, 0.1, 0.05, 0.04, 0.03, 0.02, 0.01, 0.04, 0.01, 0.008, 0.008, 0.008, 0.008, 0.0006, 0.0005, 0.00003, 0.00001}; private static final Duration DURATION = Duration.millis(3); private static void smoothScroll(ScrollPane scrollPane, double speed, double trackPadAdjustment) { final double[] derivatives = new double[FRICTIONS.length]; Timeline timeline = new Timeline(); Holder scrollDirectionHolder = new Holder<>(); final EventHandler mouseHandler = event -> timeline.stop(); final EventHandler scrollHandler = event -> { if (event.getEventType() == ScrollEvent.SCROLL) { ScrollDirection scrollDirection = determineScrollDirection(event); scrollDirectionHolder.value = scrollDirection; double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed; derivatives[0] += scrollDirection.intDirection * currentSpeed; if (timeline.getStatus() == Status.STOPPED) { timeline.play(); } event.consume(); } }; if (scrollPane.getContent().getParent() != null) { scrollPane.getContent().getParent().addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); scrollPane.getContent().getParent().addEventHandler(ScrollEvent.ANY, scrollHandler); } scrollPane.getContent().parentProperty().addListener((observable, oldValue, newValue) -> { if (oldValue != null) { oldValue.removeEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); oldValue.removeEventHandler(ScrollEvent.ANY, scrollHandler); } if (newValue != null) { newValue.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); newValue.addEventHandler(ScrollEvent.ANY, scrollHandler); } }); timeline.getKeyFrames().add(new KeyFrame(DURATION, event -> { for (int i = 0; i < derivatives.length; i++) { derivatives[i] *= FRICTIONS[i]; } for (int i = 1; i < derivatives.length; i++) { derivatives[i] += derivatives[i - 1]; } double dy = derivatives[derivatives.length - 1]; double size; switch (scrollDirectionHolder.value) { case LEFT: case RIGHT: size = scrollPane.getContent().getLayoutBounds().getWidth(); scrollPane.setHvalue(Math.min(Math.max(scrollPane.getHvalue() + dy / size, 0), 1)); break; case UP: case DOWN: size = scrollPane.getContent().getLayoutBounds().getHeight(); scrollPane.setVvalue(Math.min(Math.max(scrollPane.getVvalue() + dy / size, 0), 1)); break; } if (Math.abs(dy) < CUTOFF_DELTA) { timeline.stop(); } })); timeline.setCycleCount(Animation.INDEFINITE); } /// @author Glavo private static void smoothScroll(VirtualFlow virtualFlow, double speed, double trackPadAdjustment) { if (!virtualFlow.isVertical()) return; final double[] derivatives = new double[FRICTIONS.length]; Timeline timeline = new Timeline(); final EventHandler mouseHandler = event -> timeline.stop(); final EventHandler scrollHandler = event -> { if (event.getEventType() == ScrollEvent.SCROLL) { ScrollDirection scrollDirection = determineScrollDirection(event); if (scrollDirection == ScrollDirection.LEFT || scrollDirection == ScrollDirection.RIGHT) { return; } double currentSpeed = isTrackPad(event, scrollDirection) ? speed / trackPadAdjustment : speed; derivatives[0] += scrollDirection.intDirection * currentSpeed; if (timeline.getStatus() == Status.STOPPED) { timeline.play(); } event.consume(); } }; virtualFlow.addEventFilter(MouseEvent.MOUSE_PRESSED, mouseHandler); virtualFlow.addEventFilter(ScrollEvent.ANY, scrollHandler); timeline.getKeyFrames().add(new KeyFrame(DURATION, event -> { for (int i = 0; i < derivatives.length; i++) { derivatives[i] *= FRICTIONS[i]; } for (int i = 1; i < derivatives.length; i++) { derivatives[i] += derivatives[i - 1]; } double dy = derivatives[derivatives.length - 1]; virtualFlow.scrollPixels(dy); if (Math.abs(dy) < CUTOFF_DELTA) { timeline.stop(); } })); timeline.setCycleCount(Animation.INDEFINITE); } private ScrollUtils() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/ToolbarListPageSkin.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXListView; import javafx.beans.binding.Bindings; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.ListCell; import javafx.scene.control.SkinBase; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.ui.construct.ComponentList; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import java.util.List; public abstract class ToolbarListPageSkin> extends SkinBase

{ protected final JFXListView listView; public ToolbarListPageSkin(P skinnable) { super(skinnable); ComponentList root = new ComponentList(); root.getStyleClass().add("no-padding"); StackPane container = new StackPane(); container.getChildren().add(root); StackPane.setMargin(root, new Insets(10)); List toolbarButtons = initializeToolbar(skinnable); if (!toolbarButtons.isEmpty()) { HBox toolbar = new HBox(); toolbar.setAlignment(Pos.CENTER_LEFT); toolbar.setPickOnBounds(false); toolbar.getChildren().setAll(toolbarButtons); root.getContent().add(toolbar); } SpinnerPane spinnerPane = new SpinnerPane(); spinnerPane.loadingProperty().bind(skinnable.loadingProperty()); spinnerPane.failedReasonProperty().bind(skinnable.failedReasonProperty()); spinnerPane.onFailedActionProperty().bind(skinnable.onFailedActionProperty()); ComponentList.setVgrow(spinnerPane, Priority.ALWAYS); { this.listView = new JFXListView<>(); this.listView.setPadding(Insets.EMPTY); this.listView.setCellFactory(listView -> createListCell((JFXListView) listView)); this.listView.getStyleClass().add("no-horizontal-scrollbar"); Bindings.bindContent(this.listView.getItems(), skinnable.itemsProperty()); FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); spinnerPane.setContent(listView); } root.getContent().add(spinnerPane); getChildren().setAll(container); } public static JFXButton createToolbarButton2(String text, SVG svg, Runnable onClick) { JFXButton ret = new JFXButton(); ret.getStyleClass().add("jfx-tool-bar-button"); ret.setGraphic(svg.createIcon(20)); ret.setText(text); ret.setOnAction(e -> onClick.run()); return ret; } public static JFXButton createDecoratorButton(String tooltip, SVG svg, Runnable onClick) { JFXButton ret = new JFXButton(); ret.getStyleClass().add("jfx-decorator-button"); ret.setGraphic(svg.createIcon(20)); FXUtils.installFastTooltip(ret, tooltip); ret.setOnAction(e -> onClick.run()); return ret; } protected abstract List initializeToolbar(P skinnable); protected ListCell createListCell(JFXListView listView) { return new ListCell<>() { @Override protected void updateItem(E item, boolean empty) { super.updateItem(item, empty); if (!empty && item instanceof Node node) { setGraphic(node); setText(null); } else { setGraphic(null); setText(null); } } }; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/UpgradeDialog.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXSpinner; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; import org.jackhuang.hmcl.ui.construct.JFXHyperlink; import org.jackhuang.hmcl.upgrade.RemoteVersion; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Node; import java.net.URL; import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class UpgradeDialog extends JFXDialogLayout { public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) { maxWidthProperty().bind(Controllers.getScene().widthProperty().multiply(0.7)); maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7)); setHeading(new Label(i18n("update.changelog"))); setBody(new JFXSpinner()); String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html"; Task.supplyAsync(Schedulers.io(), () -> { VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.getVersion()); VersionNumber currentVersion = VersionNumber.asVersion(Metadata.VERSION); if (targetVersion.compareTo(currentVersion) <= 0) // Downgrade update, no need to display changelog return null; Document document = Jsoup.parse(new URL(url), 30 * 1000); Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion)); if (node == null || !"h1".equals(node.nodeName())) { LOG.warning("Changelog not found"); return null; } HTMLRenderer renderer = new HTMLRenderer(uri -> { LOG.info("Open link: " + uri); FXUtils.openLink(uri.toString()); }); do { if ("h1".equals(node.nodeName())) { String changelogVersion = node.attr("data-version"); if (StringUtils.isBlank(changelogVersion) || currentVersion.compareTo(changelogVersion) >= 0) { break; } } renderer.appendNode(node); node = node.nextSibling(); } while (node != null); renderer.mergeLineBreaks(); return renderer.render(); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { if (result != null) { ScrollPane scrollPane = new ScrollPane(result); scrollPane.setFitToWidth(true); FXUtils.smoothScrolling(scrollPane); setBody(scrollPane); } else { setBody(); } } else { LOG.warning("Failed to load update log, trying to open it in browser"); FXUtils.openLink(url); setBody(); } }).start(); JFXHyperlink openInBrowser = new JFXHyperlink(i18n("web.view_in_browser")); openInBrowser.setExternalLink(url); JFXButton updateButton = new JFXButton(i18n("update.accept")); updateButton.getStyleClass().add("dialog-accept"); updateButton.setOnAction(e -> updateRunnable.run()); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); cancelButton.getStyleClass().add("dialog-cancel"); cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); setActions(openInBrowser, updateButton, cancelButton); onEscPressed(this, cancelButton::fire); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/WeakListenerHolder.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.beans.value.WeakChangeListener; import javafx.collections.ListChangeListener; import javafx.collections.WeakListChangeListener; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.event.EventPriority; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; public final class WeakListenerHolder { private final List refs = new ArrayList<>(0); public WeakListenerHolder() { } public WeakInvalidationListener weak(InvalidationListener listener) { refs.add(listener); return new WeakInvalidationListener(listener); } public WeakChangeListener weak(ChangeListener listener) { refs.add(listener); return new WeakChangeListener<>(listener); } public WeakListChangeListener weak(ListChangeListener listener) { refs.add(listener); return new WeakListChangeListener<>(listener); } public void registerWeak(EventManager manager, Consumer consumer) { refs.add(manager.registerWeak(consumer)); } public void registerWeak(EventManager manager, Consumer consumer, EventPriority priority) { refs.add(manager.registerWeak(consumer, priority)); } public void onWeakChange(ObservableValue value, Consumer consumer) { refs.add(FXUtils.onWeakChange(value, consumer)); } public void onWeakChangeAndOperate(ObservableValue value, Consumer consumer) { refs.add(FXUtils.onWeakChangeAndOperate(value, consumer)); } public void add(Object obj) { refs.add(obj); } public boolean remove(Object obj) { return refs.remove(obj); } public void clear() { refs.clear(); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2024 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.scene.control.ScrollPane; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.paint.Color; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class WebPage extends SpinnerPane implements DecoratorPage { private final ObjectProperty stateProperty; public WebPage(String title, String content) { this.stateProperty = new SimpleObjectProperty<>(DecoratorPage.State.fromTitle(title)); this.setBackground(new Background(new BackgroundFill(Color.WHITE, null, null))); Task.supplyAsync(() -> { Document document = Jsoup.parseBodyFragment(content); HTMLRenderer renderer = new HTMLRenderer(uri -> { Controllers.confirm(i18n("web.open_in_browser", uri), i18n("message.confirm"), () -> { FXUtils.openLink(uri.toString()); }, null); }); renderer.appendNode(document); renderer.mergeLineBreaks(); return renderer.render(); }).whenComplete(Schedulers.javafx(), ((result, exception) -> { if (exception == null) { ScrollPane scrollPane = new ScrollPane(); scrollPane.setFitToWidth(true); scrollPane.setContent(result); setContent(scrollPane); setFailedReason(null); } else { LOG.warning("Failed to load content", exception); setFailedReason(i18n("web.failed")); } })).start(); } @Override public ReadOnlyObjectProperty stateProperty() { return stateProperty; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/WindowsNativeUtils.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui; import javafx.stage.Stage; import javafx.stage.Window; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.util.OptionalLong; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// @author Glavo public final class WindowsNativeUtils { public static OptionalLong getWindowHandle(Stage stage) { try { Class windowStageClass = Class.forName("com.sun.javafx.tk.quantum.WindowStage"); Class glassWindowClass = Class.forName("com.sun.glass.ui.Window"); Class tkStageClass = Class.forName("com.sun.javafx.tk.TKStage"); Object tkStage = MethodHandles.privateLookupIn(Window.class, MethodHandles.lookup()) .findVirtual(Window.class, "getPeer", MethodType.methodType(tkStageClass)) .invoke(stage); MethodHandles.Lookup windowStageLookup = MethodHandles.privateLookupIn(windowStageClass, MethodHandles.lookup()); MethodHandle getPlatformWindow = windowStageLookup.findVirtual(windowStageClass, "getPlatformWindow", MethodType.methodType(glassWindowClass)); Object platformWindow = getPlatformWindow.invoke(tkStage); long handle = (long) MethodHandles.privateLookupIn(glassWindowClass, MethodHandles.lookup()) .findVirtual(glassWindowClass, "getNativeWindow", MethodType.methodType(long.class)) .invoke(platformWindow); return OptionalLong.of(handle); } catch (Throwable ex) { LOG.warning("Failed to get window handle", ex); return OptionalLong.empty(); } } private WindowsNativeUtils() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountAdvancedListItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ObservableValue; import javafx.geometry.Pos; import javafx.scene.canvas.Canvas; import javafx.scene.control.Tooltip; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount; import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.AdvancedListItem; import org.jackhuang.hmcl.util.javafx.BindingMapping; import static javafx.beans.binding.Bindings.createStringBinding; import static org.jackhuang.hmcl.setting.Accounts.getAccountFactory; import static org.jackhuang.hmcl.setting.Accounts.getLocalizedLoginTypeName; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class AccountAdvancedListItem extends AdvancedListItem { private final Tooltip tooltip; private final Canvas canvas; private final ObjectProperty account = new SimpleObjectProperty() { @Override protected void invalidated() { Account account = get(); if (account == null) { titleProperty().unbind(); subtitleProperty().unbind(); tooltip.textProperty().unbind(); setTitle(i18n("account.missing")); setSubtitle(i18n("account.missing.add")); tooltip.setText(i18n("account.create")); TexturesLoader.unbindAvatar(canvas); TexturesLoader.drawAvatar(canvas, TexturesLoader.getDefaultSkinImage()); } else { titleProperty().bind(BindingMapping.of(account, Account::getCharacter)); subtitleProperty().bind(accountSubtitle(account)); tooltip.textProperty().bind(accountTooltip(account)); TexturesLoader.bindAvatar(canvas, account); } } }; public AccountAdvancedListItem() { this(null); } public AccountAdvancedListItem(Account account) { tooltip = new Tooltip(); FXUtils.installFastTooltip(this, tooltip); canvas = new Canvas(32, 32); canvas.setMouseTransparent(true); AdvancedListItem.setAlignment(canvas, Pos.CENTER); setLeftGraphic(canvas); if (account != null) { this.accountProperty().set(account); } else { FXUtils.onScroll(this, Accounts.getAccounts(), accounts -> accounts.indexOf(accountProperty().get()), Accounts::setSelectedAccount); } } public ObjectProperty accountProperty() { return account; } private static ObservableValue accountSubtitle(Account account) { if (account instanceof AuthlibInjectorAccount) { return BindingMapping.of(((AuthlibInjectorAccount) account).getServer(), AuthlibInjectorServer::getName); } else { return createStringBinding(() -> getLocalizedLoginTypeName(getAccountFactory(account))); } } private static ObservableValue accountTooltip(Account account) { if (account instanceof AuthlibInjectorAccount) { AuthlibInjectorServer server = ((AuthlibInjectorAccount) account).getServer(); return Bindings.format("%s (%s) (%s)", BindingMapping.of(account, Account::getCharacter), account.getUsername(), BindingMapping.of(server, AuthlibInjectorServer::getName)); } else if (account instanceof YggdrasilAccount) { return Bindings.format("%s (%s)", BindingMapping.of(account, Account::getCharacter), account.getUsername()); } else { return BindingMapping.of(account, Account::getCharacter); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; import javafx.beans.binding.StringBinding; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.beans.value.ObservableBooleanValue; import javafx.scene.control.RadioButton; import javafx.scene.control.Skin; import javafx.scene.image.Image; import javafx.stage.FileChooser; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.CredentialExpiredException; 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.CompleteGameProfile; import org.jackhuang.hmcl.auth.yggdrasil.TextureType; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.DialogController; import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.skin.InvalidSkinException; import org.jackhuang.hmcl.util.skin.NormalizedSkin; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; import java.util.Set; import java.util.concurrent.CancellationException; import static java.util.Collections.emptySet; import static javafx.beans.binding.Bindings.createBooleanBinding; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class AccountListItem extends RadioButton { private final Account account; private final StringProperty title = new SimpleStringProperty(); private final StringProperty subtitle = new SimpleStringProperty(); public AccountListItem(Account account) { this.account = account; getStyleClass().clear(); setUserData(account); String loginTypeName = Accounts.getLocalizedLoginTypeName(Accounts.getAccountFactory(account)); String portableSuffix = account.isPortable() ? ", " + i18n("account.portable") : ""; if (account instanceof AuthlibInjectorAccount) { AuthlibInjectorServer server = ((AuthlibInjectorAccount) account).getServer(); subtitle.bind(Bindings.concat( loginTypeName, ", ", i18n("account.injector.server"), ": ", Bindings.createStringBinding(server::getName, server), portableSuffix)); } else { subtitle.set(loginTypeName + portableSuffix); } StringBinding characterName = Bindings.createStringBinding(account::getCharacter, account); if (account instanceof OfflineAccount) { title.bind(characterName); } else { title.bind( account.getUsername().isEmpty() ? characterName : Bindings.concat(account.getUsername(), " - ", characterName)); } } @Override protected Skin createDefaultSkin() { return new AccountListItemSkin(this); } public Task refreshAsync() { return Task.runAsync(() -> { account.clearCache(); try { account.logIn(); } catch (CredentialExpiredException e) { try { DialogController.logIn(account); } catch (CancellationException e1) { // ignore cancellation } catch (Exception e1) { LOG.warning("Failed to refresh " + account + " with password", e1); throw e1; } } catch (AuthenticationException e) { LOG.warning("Failed to refresh " + account + " with token", e); throw e; } }); } public ObservableBooleanValue canUploadSkin() { if (account instanceof AuthlibInjectorAccount aiAccount) { ObjectBinding> profile = aiAccount.getYggdrasilService().getProfileRepository().binding(aiAccount.getUUID()); return createBooleanBinding(() -> { Set uploadableTextures = profile.get() .map(AuthlibInjectorAccount::getUploadableTextures) .orElse(emptySet()); return uploadableTextures.contains(TextureType.SKIN); }, profile); } else if (account instanceof OfflineAccount || account.canUploadSkin()) { return createBooleanBinding(() -> true); } else { return createBooleanBinding(() -> false); } } /** * @return the skin upload task, null if no file is selected */ @Nullable public Task uploadSkin() { if (account instanceof OfflineAccount) { Controllers.dialog(new OfflineAccountSkinPane((OfflineAccount) account)); return null; } if (!account.canUploadSkin()) { return null; } FileChooser chooser = new FileChooser(); chooser.setTitle(i18n("account.skin.upload")); chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("account.skin.file"), "*.png")); Path selectedFile = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage())); if (selectedFile == null) { return null; } return refreshAsync() .thenRunAsync(() -> { Image skinImg; try (var input = Files.newInputStream(selectedFile)) { skinImg = new Image(input); } catch (IOException e) { throw new InvalidSkinException("Failed to read skin image", e); } if (skinImg.isError()) { throw new InvalidSkinException("Failed to read skin image", skinImg.getException()); } NormalizedSkin skin = new NormalizedSkin(skinImg); String model = skin.isSlim() ? "slim" : ""; LOG.info("Uploading skin [" + selectedFile + "], model [" + model + "]"); account.uploadSkin(skin.isSlim(), selectedFile); }) .thenComposeAsync(refreshAsync()) .whenComplete(Schedulers.javafx(), e -> { if (e != null) { Controllers.dialog(Accounts.localizeErrorMessage(e), i18n("account.skin.upload.failed"), MessageType.ERROR); } }); } public void remove() { Accounts.getAccounts().remove(account); } public Account getAccount() { return account; } public String getTitle() { return title.get(); } public void setTitle(String title) { this.title.set(title); } public StringProperty titleProperty() { return title; } public String getSubtitle() { return subtitle.get(); } public void setSubtitle(String subtitle) { this.subtitle.set(subtitle); } public StringProperty subtitleProperty() { return subtitle; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListItemSkin.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXRadioButton; import com.jfoenix.effects.JFXDepthManager; import javafx.beans.binding.Bindings; import javafx.geometry.Pos; import javafx.scene.Cursor; import javafx.scene.canvas.Canvas; import javafx.scene.control.Label; import javafx.scene.control.SkinBase; import javafx.scene.control.Tooltip; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.SpinnerPane; import org.jackhuang.hmcl.util.javafx.BindingMapping; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class AccountListItemSkin extends SkinBase { public AccountListItemSkin(AccountListItem skinnable) { super(skinnable); BorderPane root = new BorderPane(); root.setCursor(Cursor.HAND); FXUtils.onClicked(root, skinnable::fire); JFXRadioButton chkSelected = new JFXRadioButton(); chkSelected.setMouseTransparent(true); BorderPane.setAlignment(chkSelected, Pos.CENTER); chkSelected.selectedProperty().bind(skinnable.selectedProperty()); root.setLeft(chkSelected); HBox center = new HBox(); center.setSpacing(8); center.setAlignment(Pos.CENTER_LEFT); Canvas canvas = new Canvas(32, 32); TexturesLoader.bindAvatar(canvas, skinnable.getAccount()); Label title = new Label(); title.getStyleClass().add("title"); title.textProperty().bind(skinnable.titleProperty()); Label subtitle = new Label(); subtitle.getStyleClass().add("subtitle"); subtitle.textProperty().bind(skinnable.subtitleProperty()); if (skinnable.getAccount() instanceof AuthlibInjectorAccount) { Tooltip tooltip = new Tooltip(); AuthlibInjectorServer server = ((AuthlibInjectorAccount) skinnable.getAccount()).getServer(); tooltip.textProperty().bind(BindingMapping.of(server, AuthlibInjectorServer::toString)); FXUtils.installSlowTooltip(subtitle, tooltip); } VBox item = new VBox(title, subtitle); item.getStyleClass().add("two-line-list-item"); BorderPane.setAlignment(item, Pos.CENTER); center.getChildren().setAll(canvas, item); root.setCenter(center); HBox right = new HBox(); right.setAlignment(Pos.CENTER_RIGHT); JFXButton btnMove = new JFXButton(); SpinnerPane spinnerMove = new SpinnerPane(); spinnerMove.getStyleClass().add("small-spinner-pane"); btnMove.setOnAction(e -> { Account account = skinnable.getAccount(); Accounts.getAccounts().remove(account); if (account.isPortable()) { account.setPortable(false); if (!Accounts.getAccounts().contains(account)) Accounts.getAccounts().add(account); } else { account.setPortable(true); if (!Accounts.getAccounts().contains(account)) { int idx = 0; for (int i = Accounts.getAccounts().size() - 1; i >= 0; i--) { if (Accounts.getAccounts().get(i).isPortable()) { idx = i + 1; break; } } Accounts.getAccounts().add(idx, account); } } }); btnMove.getStyleClass().add("toggle-icon4"); if (skinnable.getAccount().isPortable()) { btnMove.setGraphic(SVG.PUBLIC.createIcon()); FXUtils.installFastTooltip(btnMove, i18n("account.move_to_global")); } else { btnMove.setGraphic(SVG.OUTPUT.createIcon()); FXUtils.installFastTooltip(btnMove, i18n("account.move_to_portable")); } spinnerMove.setContent(btnMove); right.getChildren().add(spinnerMove); JFXButton btnRefresh = FXUtils.newToggleButton4(SVG.REFRESH); SpinnerPane spinnerRefresh = new SpinnerPane(); spinnerRefresh.getStyleClass().setAll("small-spinner-pane"); if (skinnable.getAccount() instanceof MicrosoftAccount && Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) { btnRefresh.setDisable(true); FXUtils.installFastTooltip(spinnerRefresh, i18n("account.methods.microsoft.snapshot.tooltip")); } btnRefresh.setOnAction(e -> { spinnerRefresh.showSpinner(); skinnable.refreshAsync() .whenComplete(Schedulers.javafx(), ex -> { spinnerRefresh.hideSpinner(); if (ex != null) { Controllers.showToast(Accounts.localizeErrorMessage(ex)); } }) .start(); }); FXUtils.installFastTooltip(btnRefresh, i18n("button.refresh")); spinnerRefresh.setContent(btnRefresh); right.getChildren().add(spinnerRefresh); JFXButton btnUpload = FXUtils.newToggleButton4(SVG.CHECKROOM); SpinnerPane spinnerUpload = new SpinnerPane(); btnUpload.setOnAction(e -> { Task uploadTask = skinnable.uploadSkin(); if (uploadTask != null) { spinnerUpload.showSpinner(); uploadTask .whenComplete(Schedulers.javafx(), ex -> spinnerUpload.hideSpinner()) .start(); } }); FXUtils.installFastTooltip(btnUpload, i18n("account.skin.upload")); btnUpload.disableProperty().bind(Bindings.not(skinnable.canUploadSkin())); spinnerUpload.setContent(btnUpload); spinnerUpload.getStyleClass().add("small-spinner-pane"); right.getChildren().add(spinnerUpload); JFXButton btnCopyUUID = FXUtils.newToggleButton4(SVG.CONTENT_COPY); SpinnerPane spinnerCopyUUID = new SpinnerPane(); spinnerCopyUUID.getStyleClass().add("small-spinner-pane"); btnCopyUUID.setOnAction(e -> FXUtils.copyText(skinnable.getAccount().getUUID().toString())); FXUtils.installFastTooltip(btnCopyUUID, i18n("account.copy_uuid")); spinnerCopyUUID.setContent(btnCopyUUID); right.getChildren().add(spinnerCopyUUID); JFXButton btnRemove = FXUtils.newToggleButton4(SVG.DELETE_FOREVER); btnRemove.setOnAction(e -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), skinnable::remove, null)); BorderPane.setAlignment(btnRemove, Pos.CENTER); FXUtils.installFastTooltip(btnRemove, i18n("button.delete")); right.getChildren().add(btnRemove); root.setRight(right); root.getStyleClass().add("card"); root.setStyle("-fx-padding: 8 8 8 0;"); JFXDepthManager.setDepth(root, 1); getChildren().setAll(root); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPage.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ListProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleListProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.scene.control.ScrollPane; import javafx.scene.control.Skin; import javafx.scene.control.Tooltip; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.AdvancedListItem; import org.jackhuang.hmcl.ui.construct.ClassTitle; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.util.i18n.LocaleUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jackhuang.hmcl.util.javafx.MappedObservableList; import java.util.Locale; import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage { static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true); static { String property = System.getProperty("hmcl.offline.auth.restricted", "auto"); if ("false".equals(property) || "auto".equals(property) && LocaleUtils.IS_CHINA_MAINLAND || globalConfig().isEnableOfflineAccount()) RESTRICTED.set(false); else globalConfig().enableOfflineAccountProperty().addListener(new ChangeListener() { @Override public void changed(ObservableValue o, Boolean oldValue, Boolean newValue) { if (newValue) { globalConfig().enableOfflineAccountProperty().removeListener(this); RESTRICTED.set(false); } } }); } private final ObservableList items; private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("account.manage"))); private final ListProperty accounts = new SimpleListProperty<>(this, "accounts", FXCollections.observableArrayList()); private final ListProperty authServers = new SimpleListProperty<>(this, "authServers", FXCollections.observableArrayList()); private final ObjectProperty selectedAccount; public AccountListPage() { items = MappedObservableList.create(accounts, AccountListItem::new); selectedAccount = createSelectedItemPropertyFor(items, Account.class); } public ObjectProperty selectedAccountProperty() { return selectedAccount; } public ListProperty accountsProperty() { return accounts; } @Override public ReadOnlyObjectProperty stateProperty() { return state.getReadOnlyProperty(); } public ListProperty authServersProperty() { return authServers; } @Override protected Skin createDefaultSkin() { return new AccountListPageSkin(this); } private static class AccountListPageSkin extends DecoratorAnimatedPageSkin { private final ObservableList authServerItems; private ChangeListener holder; public AccountListPageSkin(AccountListPage skinnable) { super(skinnable); { VBox boxMethods = new VBox(); { boxMethods.getStyleClass().add("advanced-list-box-content"); FXUtils.setLimitWidth(boxMethods, 200); AdvancedListItem microsoftItem = new AdvancedListItem(); microsoftItem.getStyleClass().add("navigation-drawer-item"); microsoftItem.setTitle(i18n("account.methods.microsoft")); microsoftItem.setLeftIcon(SVG.MICROSOFT); microsoftItem.setOnAction(e -> Controllers.dialog(new MicrosoftAccountLoginPane())); AdvancedListItem offlineItem = new AdvancedListItem(); offlineItem.getStyleClass().add("navigation-drawer-item"); offlineItem.setTitle(i18n("account.methods.offline")); offlineItem.setLeftIcon(SVG.PERSON); offlineItem.setOnAction(e -> Controllers.dialog(new CreateAccountPane(Accounts.FACTORY_OFFLINE))); VBox boxAuthServers = new VBox(); authServerItems = MappedObservableList.create(skinnable.authServersProperty(), server -> { AdvancedListItem item = new AdvancedListItem(); item.getStyleClass().add("navigation-drawer-item"); item.setLeftIcon(SVG.DRESSER); item.setOnAction(e -> Controllers.dialog(new CreateAccountPane(server))); item.setRightAction(SVG.CLOSE, () -> Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { skinnable.authServersProperty().remove(server); }, null)); ObservableValue title = BindingMapping.of(server, AuthlibInjectorServer::getName); item.titleProperty().bind(title); String host = ""; try { host = NetworkUtils.toURI(server.getUrl()).getHost(); } catch (IllegalArgumentException e) { LOG.warning("Unparsable authlib-injector server url " + server.getUrl(), e); } item.subtitleProperty().set(host); Tooltip tooltip = new Tooltip(); tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl())); FXUtils.installFastTooltip(item, tooltip); return item; }); Bindings.bindContent(boxAuthServers.getChildren(), authServerItems); ClassTitle title = new ClassTitle(i18n("account.create").toUpperCase(Locale.ROOT)); if (RESTRICTED.get()) { VBox wrapper = new VBox(offlineItem, boxAuthServers); wrapper.setPadding(Insets.EMPTY); FXUtils.installFastTooltip(wrapper, i18n("account.login.restricted")); offlineItem.setDisable(true); boxAuthServers.setDisable(true); boxMethods.getChildren().setAll(title, microsoftItem, wrapper); holder = FXUtils.onWeakChange(RESTRICTED, value -> { if (!value) { holder = null; offlineItem.setDisable(false); boxAuthServers.setDisable(false); boxMethods.getChildren().setAll(title, microsoftItem, offlineItem, boxAuthServers); } }); } else { boxMethods.getChildren().setAll(title, microsoftItem, offlineItem, boxAuthServers); } } AdvancedListItem addAuthServerItem = new AdvancedListItem(); { addAuthServerItem.getStyleClass().add("navigation-drawer-item"); addAuthServerItem.setTitle(i18n("account.injector.add")); addAuthServerItem.setSubtitle(i18n("account.methods.authlib_injector")); addAuthServerItem.setLeftIcon(SVG.ADD_CIRCLE); addAuthServerItem.setOnAction(e -> Controllers.dialog(new AddAuthlibInjectorServerPane())); VBox.setMargin(addAuthServerItem, new Insets(0, 0, 12, 0)); } ScrollPane scrollPane = new ScrollPane(boxMethods); VBox.setVgrow(scrollPane, Priority.ALWAYS); setLeft(scrollPane, addAuthServerItem); } ScrollPane scrollPane = new ScrollPane(); VBox list = new VBox(); { scrollPane.setFitToWidth(true); list.maxWidthProperty().bind(scrollPane.widthProperty()); list.setSpacing(10); list.getStyleClass().add("card-list"); Bindings.bindContent(list.getChildren(), skinnable.items); scrollPane.setContent(list); FXUtils.smoothScrolling(scrollPane); setCenter(scrollPane); } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AccountListPopupMenu.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import com.jfoenix.controls.JFXPopup; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.AdvancedListBox; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class AccountListPopupMenu extends StackPane { public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign, double initOffsetX, double initOffsetY) { var menu = new AccountListPopupMenu(); JFXPopup popup = new JFXPopup(menu); popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY); } @SuppressWarnings("FieldCanBeLocal") private final BooleanBinding isEmpty = Bindings.isEmpty(Accounts.getAccounts()); @SuppressWarnings("FieldCanBeLocal") private final InvalidationListener listener; public AccountListPopupMenu() { AdvancedListBox box = new AdvancedListBox(); box.getStyleClass().add("no-padding"); box.setPrefWidth(220); box.setPrefHeight(-1); box.setMaxHeight(260); listener = o -> { box.clear(); for (Account account : Accounts.getAccounts()) { AccountAdvancedListItem item = new AccountAdvancedListItem(account); item.setOnAction(e -> { Accounts.setSelectedAccount(account); if (getScene().getWindow() instanceof JFXPopup popup) popup.hide(); }); box.add(item); } }; listener.invalidated(null); Accounts.getAccounts().addListener(new WeakInvalidationListener(listener)); Label placeholder = new Label(i18n("account.empty")); placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;"); FXUtils.onChangeAndOperate(isEmpty, empty -> { getChildren().setAll(empty ? placeholder : box); }); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/AddAuthlibInjectorServerPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXTextField; import javafx.scene.control.Label; import javafx.scene.layout.*; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.util.Lang; import javax.net.ssl.SSLException; import java.io.IOException; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class AddAuthlibInjectorServerPane extends TransitionPane implements DialogAware { private final Label lblServerUrl; private final Label lblServerName; private final Label lblCreationWarning; private final Label lblServerWarning; private final JFXTextField txtServerUrl; private final JFXDialogLayout addServerPane; private final JFXDialogLayout confirmServerPane; private final SpinnerPane nextPane; private final JFXButton btnAddNext; private AuthlibInjectorServer serverBeingAdded; public AddAuthlibInjectorServerPane(String url) { this(); txtServerUrl.setText(url); onAddNext(); } public AddAuthlibInjectorServerPane() { addServerPane = new JFXDialogLayout(); addServerPane.setHeading(new Label(i18n("account.injector.add"))); { txtServerUrl = new JFXTextField(); txtServerUrl.setPromptText(i18n("account.injector.server_url")); txtServerUrl.setOnAction(e -> onAddNext()); lblCreationWarning = new Label(); lblCreationWarning.setWrapText(true); HBox actions = new HBox(); { JFXButton cancel = new JFXButton(i18n("button.cancel")); cancel.getStyleClass().add("dialog-accept"); cancel.setOnAction(e -> onAddCancel()); nextPane = new SpinnerPane(); nextPane.getStyleClass().add("small-spinner-pane"); btnAddNext = new JFXButton(i18n("wizard.next")); btnAddNext.getStyleClass().add("dialog-accept"); btnAddNext.setOnAction(e -> onAddNext()); nextPane.setContent(btnAddNext); actions.getChildren().setAll(cancel, nextPane); } addServerPane.setBody(txtServerUrl); addServerPane.setActions(lblCreationWarning, actions); txtServerUrl.getValidators().addAll(new RequiredValidator(), new URLValidator()); FXUtils.setValidateWhileTextChanged(txtServerUrl, true); btnAddNext.disableProperty().bind(txtServerUrl.activeValidatorProperty().isNotNull()); } confirmServerPane = new JFXDialogLayout(); confirmServerPane.setHeading(new Label(i18n("account.injector.add"))); { GridPane body = new GridPane(); body.setStyle("-fx-padding: 15 0 0 0;"); body.setVgap(15); body.setHgap(15); { body.getColumnConstraints().setAll( Lang.apply(new ColumnConstraints(), c -> c.setMaxWidth(100)), new ColumnConstraints() ); lblServerUrl = new Label(); GridPane.setColumnIndex(lblServerUrl, 1); GridPane.setRowIndex(lblServerUrl, 0); lblServerName = new Label(); GridPane.setColumnIndex(lblServerName, 1); GridPane.setRowIndex(lblServerName, 1); lblServerWarning = new Label(i18n("account.injector.http")); lblServerWarning.setStyle("-fx-text-fill: red;"); GridPane.setColumnIndex(lblServerWarning, 0); GridPane.setRowIndex(lblServerWarning, 2); lblServerWarning.managedProperty().bind(lblServerWarning.visibleProperty()); GridPane.setColumnSpan(lblServerWarning, 2); body.getChildren().setAll( Lang.apply(new Label(i18n("account.injector.server_url")), l -> { GridPane.setColumnIndex(l, 0); GridPane.setRowIndex(l, 0); }), Lang.apply(new Label(i18n("account.injector.server_name")), l -> { GridPane.setColumnIndex(l, 0); GridPane.setRowIndex(l, 1); }), lblServerUrl, lblServerName, lblServerWarning ); } JFXButton prevButton = new JFXButton(i18n("wizard.prev")); prevButton.getStyleClass().add("dialog-cancel"); prevButton.setOnAction(e -> onAddPrev()); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); cancelButton.getStyleClass().add("dialog-cancel"); cancelButton.setOnAction(e -> onAddCancel()); JFXButton finishButton = new JFXButton(i18n("wizard.finish")); finishButton.getStyleClass().add("dialog-accept"); finishButton.setOnAction(e -> onAddFinish()); confirmServerPane.setBody(body); confirmServerPane.setActions(prevButton, cancelButton, finishButton); } this.setContent(addServerPane, ContainerAnimations.NONE); lblCreationWarning.maxWidthProperty().bind(((FlowPane) lblCreationWarning.getParent()).widthProperty()); nextPane.hideSpinner(); onEscPressed(this, this::onAddCancel); } @Override public void onDialogShown() { txtServerUrl.requestFocus(); } private String resolveFetchExceptionMessage(Throwable exception) { if (exception instanceof SSLException) { if (exception.getMessage() != null && exception.getMessage().contains("Remote host terminated")) { return i18n("account.failed.connect_injector_server"); } if (exception.getMessage() != null && (exception.getMessage().contains("No name matching") || exception.getMessage().contains("No subject alternative DNS name matching"))) { return i18n("account.failed.dns"); } return i18n("account.failed.ssl"); } else if (exception instanceof IOException) { return i18n("account.failed.connect_injector_server"); } else { return exception.getClass().getName() + ": " + exception.getLocalizedMessage(); } } private void onAddCancel() { fireEvent(new DialogCloseEvent()); } private void onAddNext() { if (btnAddNext.isDisabled()) return; lblCreationWarning.setText(""); String url = txtServerUrl.getText(); nextPane.showSpinner(); addServerPane.setDisable(true); Task.runAsync(() -> { serverBeingAdded = AuthlibInjectorServer.locateServer(url); }).whenComplete(Schedulers.javafx(), exception -> { addServerPane.setDisable(false); nextPane.hideSpinner(); if (exception == null) { lblServerName.setText(serverBeingAdded.getName()); lblServerUrl.setText(serverBeingAdded.getUrl()); //noinspection HttpUrlsUsage lblServerWarning.setVisible(serverBeingAdded.getUrl().startsWith("http://")); this.setContent(confirmServerPane, ContainerAnimations.SWIPE_LEFT); } else { LOG.warning("Failed to resolve auth server: " + url, exception); lblCreationWarning.setText(resolveFetchExceptionMessage(exception)); } }).start(); } private void onAddPrev() { this.setContent(addServerPane, ContainerAnimations.SWIPE_RIGHT); } private void onAddFinish() { if (!config().getAuthlibInjectorServers().contains(serverBeingAdded)) { config().getAuthlibInjectorServers().add(serverBeingAdded); } fireEvent(new DialogCloseEvent()); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/ClassicAccountLoginDialog.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXPasswordField; import com.jfoenix.controls.JFXProgressBar; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.auth.ClassicAccount; import org.jackhuang.hmcl.auth.NoSelectedCharacterException; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; import org.jackhuang.hmcl.ui.construct.RequiredValidator; import java.util.function.Consumer; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class ClassicAccountLoginDialog extends StackPane { private final ClassicAccount oldAccount; private final Consumer success; private final Runnable failed; private final JFXPasswordField txtPassword; private final Label lblCreationWarning = new Label(); private final JFXProgressBar progressBar; public ClassicAccountLoginDialog(ClassicAccount oldAccount, Consumer success, Runnable failed) { this.oldAccount = oldAccount; this.success = success; this.failed = failed; progressBar = new JFXProgressBar(); StackPane.setAlignment(progressBar, Pos.TOP_CENTER); progressBar.setVisible(false); JFXDialogLayout dialogLayout = new JFXDialogLayout(); { dialogLayout.setHeading(new Label(i18n("login.enter_password"))); } { VBox body = new VBox(15); body.setPadding(new Insets(15, 0, 0, 0)); Label usernameLabel = new Label(oldAccount.getUsername()); txtPassword = new JFXPasswordField(); txtPassword.setOnAction(e -> onAccept()); txtPassword.getValidators().add(new RequiredValidator()); txtPassword.setLabelFloat(true); txtPassword.setPromptText(i18n("account.password")); body.getChildren().setAll(usernameLabel, txtPassword); dialogLayout.setBody(body); } { JFXButton acceptButton = new JFXButton(i18n("button.ok")); acceptButton.setOnAction(e -> onAccept()); acceptButton.getStyleClass().add("dialog-accept"); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); cancelButton.setOnAction(e -> onCancel()); cancelButton.getStyleClass().add("dialog-cancel"); dialogLayout.setActions(lblCreationWarning, acceptButton, cancelButton); } getChildren().setAll(dialogLayout); onEscPressed(this, this::onCancel); } private void onAccept() { String password = txtPassword.getText(); progressBar.setVisible(true); lblCreationWarning.setText(""); Task.supplyAsync(() -> oldAccount.logInWithPassword(password)) .whenComplete(Schedulers.javafx(), authInfo -> { success.accept(authInfo); fireEvent(new DialogCloseEvent()); progressBar.setVisible(false); }, e -> { LOG.info("Failed to login with password: " + oldAccount, e); if (e instanceof NoSelectedCharacterException) { fireEvent(new DialogCloseEvent()); } else { lblCreationWarning.setText(Accounts.localizeErrorMessage(e)); } progressBar.setVisible(false); }).start(); } private void onCancel() { failed.run(); fireEvent(new DialogCloseEvent()); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/CreateAccountPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import com.jfoenix.controls.*; import com.jfoenix.validation.base.ValidatorBase; import javafx.application.Platform; import javafx.beans.NamedArg; import javafx.beans.binding.BooleanBinding; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.geometry.HPos; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.canvas.Canvas; import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; import javafx.scene.control.TextInputControl; import javafx.scene.layout.*; import org.jackhuang.hmcl.auth.AccountFactory; import org.jackhuang.hmcl.auth.CharacterSelector; import org.jackhuang.hmcl.auth.NoSelectedCharacterException; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccountFactory; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.auth.authlibinjector.BoundAuthlibInjectorAccountFactory; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccountFactory; import org.jackhuang.hmcl.auth.offline.OfflineAccountFactory; import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.upgrade.IntegrityChecker; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter; import org.jackhuang.hmcl.util.javafx.BindingMapping; import org.jetbrains.annotations.Nullable; import java.util.*; import java.util.concurrent.CountDownLatch; import java.util.regex.Pattern; import static java.util.Collections.emptyList; import static java.util.Collections.unmodifiableList; import static javafx.beans.binding.Bindings.bindContent; import static javafx.beans.binding.Bindings.createBooleanBinding; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.ui.FXUtils.*; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.classPropertyFor; public class CreateAccountPane extends JFXDialogLayout implements DialogAware { private static final Pattern USERNAME_CHECKER_PATTERN = Pattern.compile("^[A-Za-z0-9_]+$"); private boolean showMethodSwitcher; private AccountFactory factory; private final Label lblErrorMessage; private final JFXButton btnAccept; private final SpinnerPane spinner; private final Node body; private final HBox actions; private Node detailsPane; // AccountDetailsInputPane for Offline / Mojang / authlib-injector, Label for Microsoft private final Pane detailsContainer; private final BooleanProperty logging = new SimpleBooleanProperty(); private TaskExecutor loginTask; public CreateAccountPane() { this((AccountFactory) null); } public CreateAccountPane(AccountFactory factory) { if (factory == null) { if (AccountListPage.RESTRICTED.get()) { showMethodSwitcher = false; factory = Accounts.FACTORY_MICROSOFT; } else { showMethodSwitcher = true; String preferred = config().getPreferredLoginType(); try { factory = Accounts.getAccountFactory(preferred); } catch (IllegalArgumentException e) { factory = Accounts.FACTORY_OFFLINE; } } } else { showMethodSwitcher = false; } this.factory = factory; { String title; if (showMethodSwitcher) { title = "account.create"; } else { title = "account.create." + Accounts.getLoginType(factory); } setHeading(new Label(i18n(title))); } { lblErrorMessage = new Label(); lblErrorMessage.setWrapText(true); lblErrorMessage.setMaxWidth(400); btnAccept = new JFXButton(i18n("account.login")); btnAccept.getStyleClass().add("dialog-accept"); btnAccept.setOnAction(e -> onAccept()); spinner = new SpinnerPane(); spinner.getStyleClass().add("small-spinner-pane"); spinner.setContent(btnAccept); JFXButton btnCancel = new JFXButton(i18n("button.cancel")); btnCancel.getStyleClass().add("dialog-cancel"); btnCancel.setOnAction(e -> onCancel()); onEscPressed(this, btnCancel::fire); actions = new HBox(spinner, btnCancel); actions.setAlignment(Pos.CENTER_RIGHT); setActions(lblErrorMessage, actions); } if (showMethodSwitcher) { TabControl.Tab[] tabs = new TabControl.Tab[Accounts.FACTORIES.size()]; TabControl.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].setUserData(f); if (factory == f) { selected = tabs[i]; } } TabHeader tabHeader = new TabHeader(tabs); 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(), newItem -> { if (newItem == null) return; AccountFactory newMethod = (AccountFactory) newItem.getUserData(); config().setPreferredLoginType(Accounts.getLoginType(newMethod)); this.factory = newMethod; initDetailsPane(); }); detailsContainer = new StackPane(); detailsContainer.setPadding(new Insets(15, 0, 0, 0)); VBox boxBody = new VBox(tabHeader, detailsContainer); boxBody.setAlignment(Pos.CENTER); body = boxBody; setBody(body); } else { detailsContainer = new StackPane(); detailsContainer.setPadding(new Insets(10, 0, 0, 0)); body = detailsContainer; setBody(body); } initDetailsPane(); setPrefWidth(560); } public CreateAccountPane(AuthlibInjectorServer authServer) { this(Accounts.getAccountFactoryByAuthlibInjectorServer(authServer)); } private void onAccept() { spinner.showSpinner(); lblErrorMessage.setText(""); if (!(factory instanceof MicrosoftAccountFactory)) { body.setDisable(true); } String username; String password; Object additionalData; if (detailsPane instanceof AccountDetailsInputPane) { AccountDetailsInputPane details = (AccountDetailsInputPane) detailsPane; username = details.getUsername(); password = details.getPassword(); additionalData = details.getAdditionalData(); } else { username = null; password = null; additionalData = null; } Runnable doCreate = () -> { logging.set(true); loginTask = Task.supplyAsync(() -> factory.create(new DialogCharacterSelector(), username, password, null, additionalData)) .whenComplete(Schedulers.javafx(), 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 first remove the existing one then add the new one Accounts.getAccounts().remove(oldIndex); Accounts.getAccounts().add(oldIndex, account); } // select the new account Accounts.setSelectedAccount(account); spinner.hideSpinner(); fireEvent(new DialogCloseEvent()); }, exception -> { if (exception instanceof NoSelectedCharacterException) { fireEvent(new DialogCloseEvent()); } else { lblErrorMessage.setText(Accounts.localizeErrorMessage(exception)); } body.setDisable(false); spinner.hideSpinner(); }).executor(true); }; if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) { Controllers.confirmWithCountdown(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), 10, MessageDialogPane.MessageType.WARNING, doCreate, () -> { body.setDisable(false); spinner.hideSpinner(); }); } else { doCreate.run(); } } private void onCancel() { if (loginTask != null) { loginTask.cancel(); } fireEvent(new DialogCloseEvent()); } private void initDetailsPane() { if (detailsPane != null) { btnAccept.disableProperty().unbind(); detailsContainer.getChildren().remove(detailsPane); lblErrorMessage.setText(""); setActions(lblErrorMessage, actions); } if (factory == Accounts.FACTORY_MICROSOFT) { detailsPane = new MicrosoftAccountLoginPane(true); setActions(); } else { detailsPane = new AccountDetailsInputPane(factory, btnAccept::fire); btnAccept.disableProperty().bind(((AccountDetailsInputPane) detailsPane).validProperty().not()); setActions(lblErrorMessage, actions); } detailsContainer.getChildren().add(detailsPane); } private static class AccountDetailsInputPane extends GridPane { // ==== authlib-injector hyperlinks ==== private static final String[] ALLOWED_LINKS = {"homepage", "register"}; private static List createHyperlinks(AuthlibInjectorServer server) { if (server == null) { return emptyList(); } Map links = server.getLinks(); List result = new ArrayList<>(); for (String key : ALLOWED_LINKS) { String value = links.get(key); if (value != null) { Hyperlink link = new Hyperlink(i18n("account.injector.link." + key)); FXUtils.installSlowTooltip(link, value); link.setOnAction(e -> FXUtils.openLink(value)); result.add(link); } } return unmodifiableList(result); } // ===== private final AccountFactory factory; private @Nullable AuthlibInjectorServer server; private @Nullable JFXComboBox cboServers; private @Nullable JFXTextField txtUsername; private @Nullable JFXPasswordField txtPassword; private @Nullable JFXTextField txtUUID; private final BooleanBinding valid; public AccountDetailsInputPane(AccountFactory factory, Runnable onAction) { this.factory = factory; setVgap(22); setHgap(15); setAlignment(Pos.CENTER); ColumnConstraints col0 = new ColumnConstraints(); col0.setMinWidth(USE_PREF_SIZE); getColumnConstraints().add(col0); ColumnConstraints col1 = new ColumnConstraints(); col1.setHgrow(Priority.ALWAYS); getColumnConstraints().add(col1); int rowIndex = 0; if (!IntegrityChecker.isOfficial() && !(factory instanceof OfflineAccountFactory)) { HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); hintPane.setSegment(i18n("unofficial.hint")); GridPane.setColumnSpan(hintPane, 2); add(hintPane, 0, rowIndex); rowIndex++; } if (factory instanceof BoundAuthlibInjectorAccountFactory) { this.server = ((BoundAuthlibInjectorAccountFactory) factory).getServer(); Label lblServers = new Label(i18n("account.injector.server")); setHalignment(lblServers, HPos.LEFT); add(lblServers, 0, rowIndex); Label lblServerName = new Label(this.server.getName()); lblServerName.setMaxWidth(Double.MAX_VALUE); HBox.setHgrow(lblServerName, Priority.ALWAYS); HBox linksContainer = new HBox(); linksContainer.setAlignment(Pos.CENTER); linksContainer.getChildren().setAll(createHyperlinks(this.server)); linksContainer.setMinWidth(USE_PREF_SIZE); HBox boxServers = new HBox(lblServerName, linksContainer); boxServers.setAlignment(Pos.CENTER_LEFT); add(boxServers, 1, rowIndex); rowIndex++; } else if (factory instanceof AuthlibInjectorAccountFactory) { Label lblServers = new Label(i18n("account.injector.server")); setHalignment(lblServers, HPos.LEFT); add(lblServers, 0, rowIndex); cboServers = new JFXComboBox<>(); cboServers.setCellFactory(jfxListCellFactory(server -> new TwoLineListItem(server.getName(), server.getUrl()))); cboServers.setConverter(stringConverter(AuthlibInjectorServer::getName)); bindContent(cboServers.getItems(), config().getAuthlibInjectorServers()); cboServers.getItems().addListener(onInvalidating( () -> Platform.runLater( // the selection will not be updated as expected if we call it immediately cboServers.getSelectionModel()::selectFirst))); cboServers.getSelectionModel().selectFirst(); cboServers.setPromptText(i18n("account.injector.empty")); BooleanBinding noServers = createBooleanBinding(cboServers.getItems()::isEmpty, cboServers.getItems()); classPropertyFor(cboServers, "jfx-combo-box-warning").bind(noServers); classPropertyFor(cboServers, "jfx-combo-box").bind(noServers.not()); HBox.setHgrow(cboServers, Priority.ALWAYS); HBox.setMargin(cboServers, new Insets(0, 10, 0, 0)); cboServers.setMaxWidth(Double.MAX_VALUE); HBox linksContainer = new HBox(); linksContainer.setAlignment(Pos.CENTER); onChangeAndOperate(cboServers.valueProperty(), server -> { this.server = server; linksContainer.getChildren().setAll(createHyperlinks(server)); if (txtUsername != null) txtUsername.validate(); }); linksContainer.setMinWidth(USE_PREF_SIZE); JFXButton btnAddServer = FXUtils.newToggleButton4(SVG.ADD, 20); btnAddServer.setOnAction(e -> { Controllers.dialog(new AddAuthlibInjectorServerPane()); }); HBox boxServers = new HBox(cboServers, linksContainer, btnAddServer); add(boxServers, 1, rowIndex); rowIndex++; } if (factory.getLoginType().requiresUsername) { Label lblUsername = new Label(i18n("account.username")); setHalignment(lblUsername, HPos.LEFT); add(lblUsername, 0, rowIndex); txtUsername = new JFXTextField(); txtUsername.setValidators( new RequiredValidator(), new Validator(i18n("input.email"), username -> { if (requiresEmailAsUsername()) { return username.contains("@"); } else { return true; } })); setValidateWhileTextChanged(txtUsername, true); txtUsername.setOnAction(e -> onAction.run()); add(txtUsername, 1, rowIndex); rowIndex++; } if (factory.getLoginType().requiresPassword) { Label lblPassword = new Label(i18n("account.password")); setHalignment(lblPassword, HPos.LEFT); add(lblPassword, 0, rowIndex); txtPassword = new JFXPasswordField(); txtPassword.setValidators(new RequiredValidator()); setValidateWhileTextChanged(txtPassword, true); txtPassword.setOnAction(e -> onAction.run()); add(txtPassword, 1, rowIndex); rowIndex++; } if (factory instanceof OfflineAccountFactory) { txtUsername.setPromptText(i18n("account.methods.offline.name.special_characters")); FXUtils.installFastTooltip(txtUsername, i18n("account.methods.offline.name.special_characters")); JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); HBox linkPane = new HBox(purchaseLink); GridPane.setColumnSpan(linkPane, 2); add(linkPane, 0, rowIndex); rowIndex++; HBox box = new HBox(); MenuUpDownButton advancedButton = new MenuUpDownButton(); box.getChildren().setAll(advancedButton); advancedButton.setText(i18n("settings.advanced")); GridPane.setColumnSpan(box, 2); add(box, 0, rowIndex); rowIndex++; Label lblUUID = new Label(i18n("account.methods.offline.uuid")); lblUUID.managedProperty().bind(advancedButton.selectedProperty()); lblUUID.visibleProperty().bind(advancedButton.selectedProperty()); setHalignment(lblUUID, HPos.LEFT); add(lblUUID, 0, rowIndex); txtUUID = new JFXTextField(); txtUUID.managedProperty().bind(advancedButton.selectedProperty()); txtUUID.visibleProperty().bind(advancedButton.selectedProperty()); txtUUID.setValidators(new UUIDValidator()); txtUUID.promptTextProperty().bind(BindingMapping.of(txtUsername.textProperty()).map(name -> OfflineAccountFactory.getUUIDFromUserName(name).toString())); txtUUID.setOnAction(e -> onAction.run()); add(txtUUID, 1, rowIndex); rowIndex++; HintPane hintPane = new HintPane(MessageDialogPane.MessageType.WARNING); hintPane.managedProperty().bind(advancedButton.selectedProperty()); hintPane.visibleProperty().bind(advancedButton.selectedProperty()); hintPane.setText(i18n("account.methods.offline.uuid.hint")); GridPane.setColumnSpan(hintPane, 2); add(hintPane, 0, rowIndex); rowIndex++; } valid = new BooleanBinding() { { if (cboServers != null) bind(cboServers.valueProperty()); if (txtUsername != null) bind(txtUsername.textProperty()); if (txtPassword != null) bind(txtPassword.textProperty()); if (txtUUID != null) bind(txtUUID.textProperty()); } @Override protected boolean computeValue() { if (cboServers != null && cboServers.getValue() == null) return false; if (txtUsername != null && !txtUsername.validate()) return false; if (txtPassword != null && !txtPassword.validate()) return false; if (txtUUID != null && !txtUUID.validate()) return false; return true; } }; } private boolean requiresEmailAsUsername() { if ((factory instanceof AuthlibInjectorAccountFactory) && this.server != null) { return !server.isNonEmailLogin(); } if (factory instanceof BoundAuthlibInjectorAccountFactory bound) { return !bound.getServer().isNonEmailLogin(); } return false; } public Object getAdditionalData() { if (factory instanceof AuthlibInjectorAccountFactory) { return getAuthServer(); } else if (factory instanceof OfflineAccountFactory) { UUID uuid = txtUUID == null ? null : StringUtils.isBlank(txtUUID.getText()) ? null : UUIDTypeAdapter.fromString(txtUUID.getText()); return new OfflineAccountFactory.AdditionalData(uuid, null); } else { return null; } } public @Nullable AuthlibInjectorServer getAuthServer() { return this.server; } public @Nullable String getUsername() { return txtUsername == null ? null : txtUsername.getText(); } public @Nullable String getPassword() { return txtPassword == null ? null : txtPassword.getText(); } public BooleanBinding validProperty() { return valid; } public void focus() { if (txtUsername != null) { txtUsername.requestFocus(); } } } public static class DialogCharacterSelector extends JFXDialogLayout implements CharacterSelector { private final AdvancedListBox listBox = new AdvancedListBox(); private final JFXButton cancel = new JFXButton(); private final CountDownLatch latch = new CountDownLatch(1); private GameProfile selectedProfile = null; public DialogCharacterSelector() { setStyle("-fx-padding: 8px;"); cancel.setText(i18n("button.cancel")); cancel.setOnAction(e -> latch.countDown()); cancel.getStyleClass().add("dialog-cancel"); listBox.startCategory(i18n("account.choose").toUpperCase(Locale.ROOT)); setBody(listBox); HBox hbox = new HBox(); hbox.setAlignment(Pos.CENTER_RIGHT); hbox.getChildren().add(cancel); setActions(hbox); onEscPressed(this, cancel::fire); } @Override public GameProfile select(YggdrasilService service, List profiles) throws NoSelectedCharacterException { Platform.runLater(() -> { for (GameProfile profile : profiles) { Canvas portraitCanvas = new Canvas(32, 32); TexturesLoader.bindAvatar(portraitCanvas, service, profile.getId()); IconedItem accountItem = new IconedItem(portraitCanvas, profile.getName()); FXUtils.onClicked(accountItem, () -> { selectedProfile = profile; latch.countDown(); }); listBox.add(accountItem); } Controllers.dialog(this); }); try { latch.await(); if (selectedProfile == null) throw new NoSelectedCharacterException(); return selectedProfile; } catch (InterruptedException ignored) { throw new NoSelectedCharacterException(); } finally { Platform.runLater(() -> fireEvent(new DialogCloseEvent())); } } } @Override public void onDialogShown() { if (detailsPane instanceof AccountDetailsInputPane) { ((AccountDetailsInputPane) detailsPane).focus(); } } private static class UUIDValidator extends ValidatorBase { public UUIDValidator() { this(i18n("account.methods.offline.uuid.malformed")); } public UUIDValidator(@NamedArg("message") String message) { super(message); } @Override protected void eval() { if (srcControl.get() instanceof TextInputControl) { evalTextInputField(); } } private void evalTextInputField() { TextInputControl textField = ((TextInputControl) srcControl.get()); if (StringUtils.isBlank(textField.getText())) { hasErrors.set(false); return; } try { UUIDTypeAdapter.fromString(textField.getText()); hasErrors.set(false); } catch (IllegalArgumentException ignored) { hasErrors.set(true); } } } private static final String MICROSOFT_ACCOUNT_EDIT_PROFILE_URL = "https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"; } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/MicrosoftAccountLoginPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXSpinner; import io.nayuki.qrcodegen.QrCode; import javafx.application.Platform; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.css.PseudoClass; import javafx.geometry.Pos; import javafx.scene.Cursor; import javafx.scene.Group; import javafx.scene.control.Label; import javafx.scene.layout.FlowPane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.shape.SVGPath; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.AuthInfo; import org.jackhuang.hmcl.auth.AuthenticationException; import org.jackhuang.hmcl.auth.OAuth; import org.jackhuang.hmcl.auth.microsoft.MicrosoftAccount; import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.upgrade.IntegrityChecker; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.QrCodeUtils; import org.jackhuang.hmcl.util.StringUtils; import java.util.concurrent.CancellationException; import java.util.function.Consumer; import static org.jackhuang.hmcl.setting.ConfigHolder.config; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class MicrosoftAccountLoginPane extends JFXDialogLayout implements DialogAware { private final Account accountToRelogin; private final Consumer loginCallback; private final Runnable cancelCallback; @SuppressWarnings("FieldCanBeLocal") private final WeakListenerHolder holder = new WeakListenerHolder(); private final ObjectProperty step = new SimpleObjectProperty<>(); private TaskExecutor browserTaskExecutor; private TaskExecutor deviceTaskExecutor; private final JFXButton btnLogin; private final SpinnerPane loginButtonSpinner; public MicrosoftAccountLoginPane() { this(false); } public MicrosoftAccountLoginPane(boolean bodyonly) { this(null, null, null, bodyonly); } public MicrosoftAccountLoginPane(Account account, Consumer callback, Runnable onCancel, boolean bodyonly) { this.accountToRelogin = account; this.loginCallback = callback; this.cancelCallback = onCancel; getStyleClass().add("microsoft-login-dialog"); if (bodyonly) { this.pseudoClassStateChanged(PseudoClass.getPseudoClass("bodyonly"), true); } else { Label heading = new Label(accountToRelogin != null ? i18n("account.login.refresh") : i18n("account.create.microsoft")); heading.getStyleClass().add("header-label"); setHeading(heading); } this.setMaxWidth(650); onEscPressed(this, this::onCancel); btnLogin = new JFXButton(i18n("account.login")); btnLogin.getStyleClass().add("dialog-accept"); loginButtonSpinner = new SpinnerPane(); loginButtonSpinner.getStyleClass().add("small-spinner-pane"); loginButtonSpinner.setContent(btnLogin); JFXButton btnCancel = new JFXButton(i18n("button.cancel")); btnCancel.getStyleClass().add("dialog-cancel"); btnCancel.setOnAction(e -> onCancel()); setActions(loginButtonSpinner, btnCancel); holder.registerWeak(Accounts.OAUTH_CALLBACK.onOpenBrowserAuthorizationCode, event -> Platform.runLater(() -> { if (step.get() instanceof Step.StartAuthorizationCodeLogin) step.set(new Step.WaitForOpenBrowser(event.getUrl())); })); holder.registerWeak(Accounts.OAUTH_CALLBACK.onGrantDeviceCode, event -> Platform.runLater(() -> { if (step.get() instanceof Step.StartDeviceCodeLogin) step.set(new Step.WaitForScanQrCode(event.getUserCode(), event.getVerificationUri())); })); this.step.set(Accounts.OAUTH_CALLBACK.getClientId().isEmpty() ? new Step.Init() : new Step.StartAuthorizationCodeLogin()); FXUtils.onChangeAndOperate(step, this::onStep); } private void onStep(Step currentStep) { VBox rootContainer = new VBox(10); setBody(rootContainer); rootContainer.setAlignment(Pos.TOP_CENTER); if (Accounts.OAUTH_CALLBACK.getClientId().isEmpty()) { var snapshotHint = new HintPane(MessageDialogPane.MessageType.WARNING); snapshotHint.setSegment(i18n("account.methods.microsoft.snapshot")); rootContainer.getChildren().add(snapshotHint); btnLogin.setDisable(true); loginButtonSpinner.setLoading(false); return; } if (!IntegrityChecker.isOfficial()) { var unofficialHintPane = new HintPane(MessageDialogPane.MessageType.WARNING); unofficialHintPane.setSegment(i18n("unofficial.hint")); rootContainer.getChildren().add(unofficialHintPane); } if (currentStep instanceof Step.Init) { btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin())); loginButtonSpinner.setLoading(false); var hintPane = new HintPane(MessageDialogPane.MessageType.INFO); hintPane.setText(i18n("account.methods.microsoft.hint")); rootContainer.getChildren().add(hintPane); } else if (currentStep instanceof Step.StartAuthorizationCodeLogin) { loginButtonSpinner.setLoading(false); cancelAllTasks(); rootContainer.getChildren().add(new JFXSpinner()); browserTaskExecutor = Task.supplyAsync(() -> Accounts.FACTORY_MICROSOFT.create(null, null, null, null, OAuth.GrantFlow.AUTHORIZATION_CODE)) .whenComplete(Schedulers.javafx(), this::onLoginCompleted) .executor(true); } else if (currentStep instanceof Step.StartDeviceCodeLogin) { loginButtonSpinner.setLoading(true); cancelAllTasks(); rootContainer.getChildren().add(new JFXSpinner()); deviceTaskExecutor = Task.supplyAsync(() -> Accounts.FACTORY_MICROSOFT.create(null, null, null, null, OAuth.GrantFlow.DEVICE)) .whenComplete(Schedulers.javafx(), this::onLoginCompleted) .executor(true); } else if (currentStep instanceof Step.WaitForOpenBrowser wait) { btnLogin.setOnAction(e -> { FXUtils.openLink(wait.url()); loginButtonSpinner.setLoading(true); }); loginButtonSpinner.setLoading(false); HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO); hintPane.setSegment( i18n("account.methods.microsoft.methods.browser.hint", StringUtils.escapeXmlAttribute(wait.url()), wait.url()), FXUtils::copyText ); rootContainer.getChildren().add(hintPane); } else if (currentStep instanceof Step.WaitForScanQrCode wait) { loginButtonSpinner.setLoading(true); String scanUri = "https://www.microsoft.com/link".equals(wait.verificationUri()) ? "https://www.microsoft.com/link?otc=" + wait.userCode() : wait.verificationUri(); var deviceHint = new HintPane(MessageDialogPane.MessageType.INFO); deviceHint.setSegment(i18n("account.methods.microsoft.methods.device.hint", StringUtils.escapeXmlAttribute(scanUri), wait.verificationUri(), wait.userCode() )); var qrCode = new SVGPath(); qrCode.fillProperty().bind(Themes.colorSchemeProperty().getPrimary()); qrCode.setContent(QrCodeUtils.toSVGPath(QrCode.encodeText(scanUri, QrCode.Ecc.MEDIUM))); qrCode.setScaleX(3); qrCode.setScaleY(3); var lblCode = new Label(wait.userCode()); lblCode.getStyleClass().add("code-label"); lblCode.setStyle("-fx-font-family: \"" + Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT) + "\";"); var codeBox = new StackPane(lblCode); codeBox.getStyleClass().add("code-box"); codeBox.setCursor(Cursor.HAND); FXUtils.onClicked(codeBox, () -> FXUtils.copyText(wait.userCode())); codeBox.setMaxWidth(USE_PREF_SIZE); rootContainer.getChildren().addAll(deviceHint, new Group(qrCode), codeBox); } else if (currentStep instanceof Step.LoginFailed failed) { btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin())); loginButtonSpinner.setLoading(false); cancelAllTasks(); HintPane errHintPane = new HintPane(MessageDialogPane.MessageType.ERROR); errHintPane.setSegment(failed.message()); rootContainer.getChildren().add(errHintPane); } var linkBox = new FlowPane(8, 8); linkBox.setAlignment(Pos.CENTER_LEFT); linkBox.setPrefWrapLength(500); if (currentStep instanceof Step.Init || currentStep instanceof Step.StartAuthorizationCodeLogin || currentStep instanceof Step.WaitForOpenBrowser) { JFXHyperlink useQrCode = new JFXHyperlink(i18n("account.methods.microsoft.methods.device")); useQrCode.setOnAction(e -> this.step.set(new Step.StartDeviceCodeLogin())); linkBox.getChildren().add(useQrCode); } else if (currentStep instanceof Step.StartDeviceCodeLogin || currentStep instanceof Step.WaitForScanQrCode) { JFXHyperlink userBrowser = new JFXHyperlink(i18n("account.methods.microsoft.methods.browser")); userBrowser.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin())); linkBox.getChildren().add(userBrowser); } JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile")); profileLink.setExternalLink("https://account.live.com/editprof.aspx"); JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.microsoft.purchase")); purchaseLink.setExternalLink(YggdrasilService.PURCHASE_URL); linkBox.getChildren().addAll(profileLink, purchaseLink); rootContainer.getChildren().add(linkBox); setBody(rootContainer); } private void cancelAllTasks() { if (browserTaskExecutor != null) browserTaskExecutor.cancel(); if (deviceTaskExecutor != null) deviceTaskExecutor.cancel(); } private void onCancel() { cancelAllTasks(); if (cancelCallback != null) cancelCallback.run(); fireEvent(new DialogCloseEvent()); } private void onLoginCompleted(MicrosoftAccount account, Exception exception) { if (exception == null) { if (accountToRelogin != null) Accounts.getAccounts().remove(accountToRelogin); int oldIndex = Accounts.getAccounts().indexOf(account); if (oldIndex == -1) { Accounts.getAccounts().add(account); } else { Accounts.getAccounts().remove(oldIndex); Accounts.getAccounts().add(oldIndex, account); } Accounts.setSelectedAccount(account); if (loginCallback != null) { try { loginCallback.accept(account.logIn()); } catch (AuthenticationException e) { this.step.set(new Step.LoginFailed(Accounts.localizeErrorMessage(e))); return; } } fireEvent(new DialogCloseEvent()); } else if (!(exception instanceof CancellationException)) { this.step.set(new Step.LoginFailed(Accounts.localizeErrorMessage(exception))); } } private sealed interface Step { final class Init implements Step { } final class StartAuthorizationCodeLogin implements Step { } record WaitForOpenBrowser(String url) implements Step { } final class StartDeviceCodeLogin implements Step { } record WaitForScanQrCode(String userCode, String verificationUri) implements Step { } record LoginFailed(String message) implements Step { } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/account/OfflineAccountSkinPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.account; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXTextField; import javafx.application.Platform; import javafx.beans.InvalidationListener; import javafx.geometry.Insets; import javafx.geometry.VPos; import javafx.scene.control.Label; import javafx.scene.input.DragEvent; import javafx.scene.input.TransferMode; import javafx.scene.layout.*; import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.auth.offline.Skin; import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; import org.jackhuang.hmcl.game.TexturesLoader; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.skin.SkinCanvas; import org.jackhuang.hmcl.ui.skin.animation.SkinAniRunning; import org.jackhuang.hmcl.ui.skin.animation.SkinAniWavingArms; import org.jackhuang.hmcl.util.io.FileUtils; import java.nio.file.Path; import java.util.Arrays; import java.util.UUID; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.ui.FXUtils.stringConverter; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class OfflineAccountSkinPane extends StackPane { private final OfflineAccount account; private final MultiFileItem skinItem = new MultiFileItem<>(); private final JFXTextField cslApiField = new JFXTextField(); private final JFXComboBox modelCombobox = new JFXComboBox<>(); private final FileSelector skinSelector = new FileSelector(); private final FileSelector capeSelector = new FileSelector(); private final InvalidationListener skinBinding; public OfflineAccountSkinPane(OfflineAccount account) { this.account = account; getStyleClass().add("skin-pane"); JFXDialogLayout layout = new JFXDialogLayout(); getChildren().setAll(layout); layout.setHeading(new Label(i18n("account.skin"))); BorderPane pane = new BorderPane(); SkinCanvas canvas = new SkinCanvas(TexturesLoader.getDefaultSkinImage(), 300, 300, true); StackPane canvasPane = new StackPane(canvas); canvasPane.setPrefWidth(300); canvasPane.setPrefHeight(300); pane.setCenter(canvas); canvas.getAnimationPlayer().addSkinAnimation(new SkinAniWavingArms(100, 2000, 7.5, canvas), new SkinAniRunning(100, 100, 30, canvas)); canvas.enableRotation(.5); canvas.addEventHandler(DragEvent.DRAG_OVER, e -> { if (e.getDragboard().hasFiles()) { Path file = e.getDragboard().getFiles().get(0).toPath(); if (FileUtils.getName(file).endsWith(".png")) e.acceptTransferModes(TransferMode.COPY); } }); canvas.addEventHandler(DragEvent.DRAG_DROPPED, e -> { if (e.isAccepted()) { Path skin = e.getDragboard().getFiles().get(0).toPath(); Platform.runLater(() -> { skinSelector.setValue(FileUtils.getAbsolutePath(skin)); skinItem.setSelectedData(Skin.Type.LOCAL_FILE); }); } }); StackPane skinOptionPane = new StackPane(); skinOptionPane.setMaxWidth(300); VBox optionPane = new VBox(skinItem, skinOptionPane); pane.setRight(optionPane); skinSelector.maxWidthProperty().bind(skinOptionPane.maxWidthProperty().multiply(0.7)); capeSelector.maxWidthProperty().bind(skinOptionPane.maxWidthProperty().multiply(0.7)); layout.setBody(pane); cslApiField.setPromptText(i18n("account.skin.type.csl_api.location.hint")); cslApiField.setValidators(new URLValidator()); FXUtils.setValidateWhileTextChanged(cslApiField, true); skinItem.loadChildren(Arrays.asList( new MultiFileItem.Option<>(i18n("message.default"), Skin.Type.DEFAULT), new MultiFileItem.Option<>(i18n("account.skin.type.steve"), Skin.Type.STEVE), new MultiFileItem.Option<>(i18n("account.skin.type.alex"), Skin.Type.ALEX), new MultiFileItem.Option<>(i18n("account.skin.type.local_file"), Skin.Type.LOCAL_FILE), new MultiFileItem.Option<>(i18n("account.skin.type.little_skin"), Skin.Type.LITTLE_SKIN), new MultiFileItem.Option<>(i18n("account.skin.type.csl_api"), Skin.Type.CUSTOM_SKIN_LOADER_API) )); modelCombobox.setConverter(stringConverter(model -> i18n("account.skin.model." + model.modelName))); modelCombobox.getItems().setAll(TextureModel.WIDE, TextureModel.SLIM); if (account.getSkin() == null) { skinItem.setSelectedData(Skin.Type.DEFAULT); modelCombobox.setValue(TextureModel.WIDE); } else { skinItem.setSelectedData(account.getSkin().getType()); cslApiField.setText(account.getSkin().getCslApi()); modelCombobox.setValue(account.getSkin().getTextureModel()); skinSelector.setValue(account.getSkin().getLocalSkinPath()); capeSelector.setValue(account.getSkin().getLocalCapePath()); } skinBinding = FXUtils.observeWeak(() -> { getSkin().load(account.getUsername()) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception != null) { LOG.warning("Failed to load skin", exception); Controllers.showToast(i18n("message.failed")); } else { UUID uuid = this.account.getUUID(); if (result == null || result.getSkin() == null && result.getCape() == null) { canvas.updateSkin( TexturesLoader.getDefaultSkin(uuid).getImage(), TexturesLoader.getDefaultModel(uuid) == TextureModel.SLIM, null ); return; } canvas.updateSkin( result.getSkin() != null ? result.getSkin().getImage() : TexturesLoader.getDefaultSkin(uuid).getImage(), result.getModel() == TextureModel.SLIM, result.getCape() != null ? result.getCape().getImage() : null); } }).start(); }, skinItem.selectedDataProperty(), cslApiField.textProperty(), modelCombobox.valueProperty(), skinSelector.valueProperty(), capeSelector.valueProperty()); FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> { GridPane gridPane = new GridPane(); // Increase bottom padding to prevent the prompt from overlapping with the dialog action area gridPane.setPadding(new Insets(0, 0, 45, 10)); gridPane.setHgap(16); gridPane.setVgap(8); gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); switch (selectedData) { case DEFAULT: case STEVE: case ALEX: break; case LITTLE_SKIN: HintPane hint = new HintPane(MessageDialogPane.MessageType.INFO); hint.setText(i18n("account.skin.type.little_skin.hint")); // Spanning two columns and expanding horizontally GridPane.setColumnSpan(hint, 2); GridPane.setHgrow(hint, Priority.ALWAYS); hint.setMaxWidth(Double.MAX_VALUE); // Force top alignment within cells (to avoid vertical offset caused by the baseline) GridPane.setValignment(hint, VPos.TOP); // Set a fixed height as the preferred height to prevent the GridPane from stretching or leaving empty space. hint.setMaxHeight(Region.USE_PREF_SIZE); hint.setMinHeight(Region.USE_PREF_SIZE); gridPane.addRow(0, hint); break; case LOCAL_FILE: gridPane.setPadding(new Insets(0, 0, 0, 10)); gridPane.addRow(0, new Label(i18n("account.skin.model")), modelCombobox); gridPane.addRow(1, new Label(i18n("account.skin")), skinSelector); gridPane.addRow(2, new Label(i18n("account.cape")), capeSelector); break; case CUSTOM_SKIN_LOADER_API: gridPane.addRow(0, new Label(i18n("account.skin.type.csl_api.location")), cslApiField); break; } skinOptionPane.getChildren().setAll(gridPane); }); JFXButton acceptButton = new JFXButton(i18n("button.ok")); acceptButton.getStyleClass().add("dialog-accept"); acceptButton.setOnAction(e -> { account.setSkin(getSkin()); fireEvent(new DialogCloseEvent()); }); JFXHyperlink littleSkinLink = new JFXHyperlink(i18n("account.skin.type.little_skin")); littleSkinLink.setOnAction(e -> FXUtils.openLink("https://littleskin.cn/")); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); cancelButton.getStyleClass().add("dialog-cancel"); cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); onEscPressed(this, cancelButton::fire); acceptButton.disableProperty().bind( skinItem.selectedDataProperty().isEqualTo(Skin.Type.CUSTOM_SKIN_LOADER_API) .and(cslApiField.activeValidatorProperty().isNotNull())); layout.setActions(littleSkinLink, acceptButton, cancelButton); } private Skin getSkin() { Skin.Type type = skinItem.getSelectedData(); if (type == Skin.Type.LOCAL_FILE) { return new Skin(type, cslApiField.getText(), modelCombobox.getValue(), skinSelector.getValue(), capeSelector.getValue()); } else { String cslApi = type == Skin.Type.CUSTOM_SKIN_LOADER_API ? cslApiField.getText() : null; return new Skin(type, cslApi, null, null, null); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/AnimationUtils.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.animation; import javafx.scene.Node; import org.jackhuang.hmcl.setting.ConfigHolder; import org.jackhuang.hmcl.util.platform.OperatingSystem; /** * @author Glavo */ public final class AnimationUtils { private AnimationUtils() { } /** * Trigger initialization of this class. * Should be called from {@link org.jackhuang.hmcl.setting.Settings#init()}. */ @SuppressWarnings("JavadocReference") public static void init() { } private static final boolean ENABLED = !ConfigHolder.config().isAnimationDisabled(); private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD(); public static boolean isAnimationEnabled() { return ENABLED; } public static boolean playWindowAnimation() { return PLAY_WINDOW_ANIMATION; } public static void reset(Node node, boolean opaque) { node.setTranslateX(0); node.setTranslateY(0); node.setScaleX(1); node.setScaleY(1); node.setOpacity(opaque ? 1 : 0); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/ContainerAnimations.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.animation; import javafx.animation.*; import javafx.scene.Node; import javafx.scene.layout.Pane; import javafx.util.Duration; import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage; public enum ContainerAnimations implements TransitionPane.AnimationProducer { NONE { @Override public void init(TransitionPane container, Node previousNode, Node nextNode) { AnimationUtils.reset(previousNode, false); AnimationUtils.reset(nextNode, true); } @Override public Timeline animate( Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) { return new Timeline(); } @Override public TransitionPane.AnimationProducer opposite() { return this; } }, /** * A fade between the old and new view */ FADE { @Override public Timeline animate( Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) { return new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(previousNode.opacityProperty(), 1, interpolator), new KeyValue(nextNode.opacityProperty(), 0, interpolator)), new KeyFrame(duration, new KeyValue(previousNode.opacityProperty(), 0, interpolator), new KeyValue(nextNode.opacityProperty(), 1, interpolator))); } @Override public TransitionPane.AnimationProducer opposite() { return this; } }, /** * A swipe effect */ SWIPE_LEFT { @Override public void init(TransitionPane container, Node previousNode, Node nextNode) { AnimationUtils.reset(previousNode, true); AnimationUtils.reset(nextNode, true); nextNode.setTranslateX(container.getWidth()); } @Override public Timeline animate( Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) { return new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(nextNode.translateXProperty(), container.getWidth(), interpolator), new KeyValue(previousNode.translateXProperty(), 0, interpolator)), new KeyFrame(duration, new KeyValue(nextNode.translateXProperty(), 0, interpolator), new KeyValue(previousNode.translateXProperty(), -container.getWidth(), interpolator))); } @Override public TransitionPane.AnimationProducer opposite() { return SWIPE_RIGHT; } }, /** * A swipe effect */ SWIPE_RIGHT { @Override public void init(TransitionPane container, Node previousNode, Node nextNode) { AnimationUtils.reset(previousNode, true); AnimationUtils.reset(nextNode, true); nextNode.setTranslateX(-container.getWidth()); } @Override public Timeline animate( Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) { return new Timeline(new KeyFrame(Duration.ZERO, new KeyValue(nextNode.translateXProperty(), -container.getWidth(), interpolator), new KeyValue(previousNode.translateXProperty(), 0, interpolator)), new KeyFrame(duration, new KeyValue(nextNode.translateXProperty(), 0, interpolator), new KeyValue(previousNode.translateXProperty(), container.getWidth(), interpolator))); } @Override public TransitionPane.AnimationProducer opposite() { return SWIPE_LEFT; } }, /// @see Transitions - Material Design 3 FORWARD { @Override public Timeline animate( Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) { double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50; return new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(previousNode.translateXProperty(), 0, interpolator), new KeyValue(previousNode.opacityProperty(), 1, interpolator), new KeyValue(nextNode.opacityProperty(), 0, interpolator)), new KeyFrame(duration.multiply(0.5), new KeyValue(previousNode.translateXProperty(), -offset, interpolator), new KeyValue(previousNode.opacityProperty(), 0, interpolator), new KeyValue(nextNode.opacityProperty(), 0, interpolator), new KeyValue(nextNode.translateXProperty(), offset, interpolator)), new KeyFrame(duration, new KeyValue(nextNode.opacityProperty(), 1, interpolator), new KeyValue(nextNode.translateXProperty(), 0, interpolator)) ); } @Override public TransitionPane.AnimationProducer opposite() { return BACKWARD; } }, /// @see Transitions - Material Design 3 BACKWARD { @Override public Timeline animate( Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) { double offset = container.getWidth() > 0 ? container.getWidth() * 0.2 : 50; return new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(previousNode.translateXProperty(), 0, interpolator), new KeyValue(previousNode.opacityProperty(), 1, interpolator), new KeyValue(nextNode.opacityProperty(), 0, interpolator)), new KeyFrame(duration.multiply(0.5), new KeyValue(previousNode.translateXProperty(), offset, interpolator), new KeyValue(previousNode.opacityProperty(), 0, interpolator), new KeyValue(nextNode.opacityProperty(), 0, interpolator), new KeyValue(nextNode.translateXProperty(), -offset, interpolator)), new KeyFrame(duration, new KeyValue(nextNode.opacityProperty(), 1, interpolator), new KeyValue(nextNode.translateXProperty(), 0, interpolator)) ); } @Override public TransitionPane.AnimationProducer opposite() { return FORWARD; } }, /// Imitates the animation when switching tabs in the Windows 11 Settings interface SLIDE_UP_FADE_IN { @Override public Timeline animate( Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) { double offset = container.getHeight() > 0 ? container.getHeight() * 0.2 : 50; return new Timeline( new KeyFrame(Duration.ZERO, new KeyValue(previousNode.translateYProperty(), 0, interpolator), new KeyValue(previousNode.opacityProperty(), 1, interpolator), new KeyValue(nextNode.opacityProperty(), 0, interpolator), new KeyValue(nextNode.translateYProperty(), offset, interpolator)), new KeyFrame(duration.multiply(0.5), new KeyValue(previousNode.opacityProperty(), 0, interpolator)), new KeyFrame(duration, new KeyValue(nextNode.opacityProperty(), 1, interpolator), new KeyValue(nextNode.translateYProperty(), 0, interpolator)) ); } }, NAVIGATION { @Override public Animation animate(Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator) { Timeline timeline = new Timeline(); Duration halfDuration = duration.divide(2); timeline.getKeyFrames().add(new KeyFrame(Duration.ZERO, new KeyValue(previousNode.opacityProperty(), 1, interpolator))); timeline.getKeyFrames().add(new KeyFrame(halfDuration, new KeyValue(previousNode.opacityProperty(), 0, interpolator))); if (previousNode instanceof DecoratorAnimatedPage prevPage) { Node left = prevPage.getLeft(); Node center = prevPage.getCenter(); timeline.getKeyFrames().add(new KeyFrame(Duration.ZERO, new KeyValue(left.translateXProperty(), 0, interpolator), new KeyValue(center.translateXProperty(), 0, interpolator))); timeline.getKeyFrames().add(new KeyFrame(halfDuration, new KeyValue(left.translateXProperty(), -30, interpolator), new KeyValue(center.translateXProperty(), 30, interpolator))); } timeline.getKeyFrames().add(new KeyFrame(Duration.ZERO, new KeyValue(nextNode.opacityProperty(), 0, interpolator))); timeline.getKeyFrames().add(new KeyFrame(halfDuration, new KeyValue(nextNode.opacityProperty(), 0, interpolator))); timeline.getKeyFrames().add(new KeyFrame(duration, new KeyValue(nextNode.opacityProperty(), 1, interpolator))); if (nextNode instanceof DecoratorAnimatedPage nextPage) { Node left = nextPage.getLeft(); Node center = nextPage.getCenter(); timeline.getKeyFrames().add(new KeyFrame(halfDuration, new KeyValue(left.translateXProperty(), -30, interpolator), new KeyValue(center.translateXProperty(), 30, interpolator))); timeline.getKeyFrames().add(new KeyFrame(duration, new KeyValue(left.translateXProperty(), 0, interpolator), new KeyValue(center.translateXProperty(), 0, interpolator))); } return timeline; } }, ; } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/Motion.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.animation; import javafx.animation.Interpolator; import javafx.util.Duration; import java.util.Objects; /// @author Glavo /// @see Flutter Curves public final class Motion { //region Curves /// A linear animation curve. /// /// This is the identity map over the unit interval: its [Interpolator#curve(double)] /// method returns its input unmodified. This is useful as a default curve for /// cases where a [Interpolator] is required but no actual curve is desired. /// /// @see curve_linear.mp4 public static final Interpolator LINEAR = Interpolator.LINEAR; /// The emphasizedAccelerate easing curve in the Material specification. /// /// See also: /// /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) public static final Interpolator EMPHASIZED_ACCELERATE = new Cubic(0.3, 0.0, 0.8, 0.15); /// The emphasizedDecelerate easing curve in the Material specification. /// /// See also: /// /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) public static final Interpolator EMPHASIZED_DECELERATE = new Cubic(0.05, 0.7, 0.1, 1.0); /// The standard easing curve in the Material specification. /// /// See also: /// /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) public static final Interpolator STANDARD = new Cubic(0.2, 0.0, 0.0, 1.0); /// The standardAccelerate easing curve in the Material specification. /// /// See also: /// /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) public static final Interpolator STANDARD_ACCELERATE = new Cubic(0.3, 0.0, 1.0, 1.0); /// The standardDecelerate easing curve in the Material specification. /// /// See also: /// /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) public static Interpolator STANDARD_DECELERATE = new Cubic(0.0, 0.0, 0.0, 1.0); /// The legacyDecelerate easing curve in the Material specification. /// /// See also: /// /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) public static Interpolator LEGACY_DECELERATE = new Cubic(0.0, 0.0, 0.2, 1.0); /// The legacyAccelerate easing curve in the Material specification. /// /// See also: /// /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) public static Interpolator LEGACY_ACCELERATE = new Cubic(0.4, 0.0, 1.0, 1.0); /// The legacy easing curve in the Material specification. /// /// See also: /// /// * [M3 guidelines: Easing tokens](https://m3.material.io/styles/motion/easing-and-duration/tokens-specs#433b1153-2ea3-4fe2-9748-803a47bc97ee) /// * [M3 guidelines: Applying easing and duration](https://m3.material.io/styles/motion/easing-and-duration/applying-easing-and-duration) public static Interpolator LEGACY = new Cubic(0.4, 0.0, 0.2, 1.0); /// A cubic animation curve that speeds up quickly and ends slowly. /// /// This is the same as the CSS easing function `ease`. /// /// @see curve_ease.mp4 public static final Interpolator EASE = new Cubic(0.25, 0.1, 0.25, 1.0); /// A cubic animation curve that starts slowly and ends quickly. /// /// This is the same as the CSS easing function `ease-in`. /// /// @see curve_ease_in.mp4 public static final Interpolator EASE_IN = new Cubic(0.42, 0.0, 1.0, 1.0); /// A cubic animation curve that starts slowly and ends linearly. /// /// The symmetric animation to [#LINEAR_TO_EASE_OUT]. /// /// @see curve_ease_in_to_linear.mp4 public static final Interpolator EASE_IN_TO_LINEAR = new Cubic(0.67, 0.03, 0.65, 0.09); /// A cubic animation curve that starts slowly and ends quickly. This is /// similar to [#EASE_IN], but with sinusoidal easing for a slightly less /// abrupt beginning and end. Nonetheless, the result is quite gentle and is /// hard to distinguish from [#linear] at a glance. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_sine.mp4 public static final Interpolator EASE_IN_SINE = new Cubic(0.47, 0.0, 0.745, 0.715); /// A cubic animation curve that starts slowly and ends quickly. Based on a /// quadratic equation where `f(t) = t²`, this is effectively the inverse of /// [#decelerate]. /// /// Compared to [#EASE_IN_SINE], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_quad.mp4 public static final Interpolator EASE_IN_QUAD = new Cubic(0.55, 0.085, 0.68, 0.53); /// A cubic animation curve that starts slowly and ends quickly. This curve is /// based on a cubic equation where `f(t) = t³`. The result is a safe sweet /// spot when choosing a curve for widgets animating off the viewport. /// /// Compared to [#EASE_IN_QUAD], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_cubic.mp4 public static final Interpolator EASE_IN_CUBIC = new Cubic(0.55, 0.055, 0.675, 0.19); /// A cubic animation curve that starts slowly and ends quickly. This curve is /// based on a quartic equation where `f(t) = t⁴`. /// /// Animations using this curve or steeper curves will benefit from a longer /// duration to avoid motion feeling unnatural. /// /// Compared to [#EASE_IN_CUBIC], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_quart.mp4 public static final Interpolator EASE_IN_QUART = new Cubic(0.895, 0.03, 0.685, 0.22); /// A cubic animation curve that starts slowly and ends quickly. This curve is /// based on a quintic equation where `f(t) = t⁵`. /// /// Compared to [#EASE_IN_QUART], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_quint.mp4 public static final Interpolator EASE_IN_QUINT = new Cubic(0.755, 0.05, 0.855, 0.06); /// A cubic animation curve that starts slowly and ends quickly. This curve is /// based on an exponential equation where `f(t) = 2¹⁰⁽ᵗ⁻¹⁾`. /// /// Using this curve can give your animations extra flare, but a longer /// duration may need to be used to compensate for the steepness of the curve. /// /// Compared to [#EASE_IN_QUINT], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_expo.mp4 public static final Interpolator EASE_IN_EXPO = new Cubic(0.95, 0.05, 0.795, 0.035); /// A cubic animation curve that starts slowly and ends quickly. This curve is /// effectively the bottom-right quarter of a circle. /// /// Like [#EASE_IN_EXPO], this curve is fairly dramatic and will reduce /// the clarity of an animation if not given a longer duration. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_circ.mp4 public static final Interpolator EASE_IN_CIRC = new Cubic(0.6, 0.04, 0.98, 0.335); /// A cubic animation curve that starts slowly and ends quickly. This curve /// is similar to [#elasticIn] in that it overshoots its bounds before /// reaching its end. Instead of repeated swinging motions before ascending, /// though, this curve overshoots once, then continues to ascend. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_back.mp4 public static final Interpolator EASE_IN_BACK = new Cubic(0.6, -0.28, 0.735, 0.045); /// A cubic animation curve that starts quickly and ends slowly. /// /// This is the same as the CSS easing function `ease-out`. /// /// @see curve_ease_out.mp4 public static final Interpolator EASE_OUT = new Cubic(0.0, 0.0, 0.58, 1.0); /// A cubic animation curve that starts linearly and ends slowly. /// /// A symmetric animation to [#EASE_IN_TO_LINEAR]. /// /// @see curve_linear_to_ease_out.mp4 public static final Interpolator LINEAR_TO_EASE_OUT = new Cubic(0.35, 0.91, 0.33, 0.97); /// A cubic animation curve that starts quickly and ends slowly. This is /// similar to [#EASE_OUT], but with sinusoidal easing for a slightly /// less abrupt beginning and end. Nonetheless, the result is quite gentle and /// is hard to distinguish from [#linear] at a glance. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_out_sine.mp4 public static final Interpolator EASE_OUT_SINE = new Cubic(0.39, 0.575, 0.565, 1.0); /// A cubic animation curve that starts quickly and ends slowly. This is /// effectively the same as [#decelerate], only simulated using a cubic /// bezier function. /// /// Compared to [#EASE_OUT_SINE], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_out_quad.mp4 public static final Interpolator EASE_OUT_QUAD = new Cubic(0.25, 0.46, 0.45, 0.94); /// A cubic animation curve that starts quickly and ends slowly. This curve is /// a flipped version of [#EASE_IN_CUBIC]. /// /// The result is a safe sweet spot when choosing a curve for animating a /// widget's position entering or already inside the viewport. /// /// Compared to [#EASE_OUT_QUAD], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_out_cubic.mp4 public static final Interpolator EASE_OUT_CUBIC = new Cubic(0.215, 0.61, 0.355, 1.0); /// A cubic animation curve that starts quickly and ends slowly. This curve is /// a flipped version of [#EASE_IN_QUART]. /// /// Animations using this curve or steeper curves will benefit from a longer /// duration to avoid motion feeling unnatural. /// /// Compared to [#EASE_OUT_CUBIC], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_out_quart.mp4 public static final Interpolator EASE_OUT_QUART = new Cubic(0.165, 0.84, 0.44, 1.0); /// A cubic animation curve that starts quickly and ends slowly. This curve is /// a flipped version of [#EASE_IN_QUINT]. /// /// Compared to [#EASE_OUT_QUART], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_out_quint.mp4 public static final Interpolator EASE_OUT_QUINT = new Cubic(0.23, 1.0, 0.32, 1.0); /// A cubic animation curve that starts quickly and ends slowly. This curve is /// a flipped version of [#EASE_IN_EXPO]. Using this curve can give your /// animations extra flare, but a longer duration may need to be used to /// compensate for the steepness of the curve. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_out_expo.mp4 public static final Interpolator EASE_OUT_EXPO = new Cubic(0.19, 1.0, 0.22, 1.0); /// A cubic animation curve that starts quickly and ends slowly. This curve is /// effectively the top-left quarter of a circle. /// /// Like [#EASE_OUT_EXPO], this curve is fairly dramatic and will reduce /// the clarity of an animation if not given a longer duration. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_out_circ.mp4 public static final Interpolator EASE_OUT_CIRC = new Cubic(0.075, 0.82, 0.165, 1.0); /// A cubic animation curve that starts quickly and ends slowly. This curve is /// similar to [#elasticOut] in that it overshoots its bounds before /// reaching its end. Instead of repeated swinging motions after ascending, /// though, this curve only overshoots once. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_out_back.mp4 public static final Interpolator EASE_OUT_BACK = new Cubic(0.175, 0.885, 0.32, 1.275); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. /// /// This is the same as the CSS easing function `ease-in-out`. /// /// @see curve_ease_in_out.mp4 public static final Interpolator EASE_IN_OUT = new Cubic(0.42, 0.0, 0.58, 1.0); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. This is similar to [#EASE_IN_OUT], but with sinusoidal easing /// for a slightly less abrupt beginning and end. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_out_sine.mp4 public static final Interpolator EASE_IN_OUT_SINE = new Cubic(0.445, 0.05, 0.55, 0.95); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. This curve can be imagined as [#EASE_IN_QUAD] as the first /// half, and [#EASE_OUT_QUAD] as the second. /// /// Compared to [#EASE_IN_OUT_SINE], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_out_quad.mp4 public static final Interpolator EASE_IN_OUT_QUAD = new Cubic(0.455, 0.03, 0.515, 0.955); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. This curve can be imagined as [#EASE_IN_CUBIC] as the first /// half, and [#EASE_OUT_CUBIC] as the second. /// /// The result is a safe sweet spot when choosing a curve for a widget whose /// initial and final positions are both within the viewport. /// /// Compared to [#EASE_IN_OUT_QUAD], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_out_cubic.mp4 public static final Interpolator EASE_IN_OUT_CUBIC = new Cubic(0.645, 0.045, 0.355, 1.0); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. This curve can be imagined as [#EASE_IN_QUART] as the first /// half, and [#EASE_OUT_QUART] as the second. /// /// Animations using this curve or steeper curves will benefit from a longer /// duration to avoid motion feeling unnatural. /// /// Compared to [#EASE_IN_OUT_CUBIC], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_out_quart.mp4 public static final Interpolator EASE_IN_OUT_QUART = new Cubic(0.77, 0.0, 0.175, 1.0); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. This curve can be imagined as [#EASE_IN_QUINT] as the first /// half, and [#EASE_OUT_QUINT] as the second. /// /// Compared to [#EASE_IN_OUT_QUART], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_out_quint.mp4 public static final Interpolator EASE_IN_OUT_QUINT = new Cubic(0.86, 0.0, 0.07, 1.0); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. /// /// Since this curve is arrived at with an exponential function, the midpoint /// is exceptionally steep. Extra consideration should be taken when designing /// an animation using this. /// /// Compared to [#EASE_IN_OUT_QUINT], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_out_expo.mp4 public static final Interpolator EASE_IN_OUT_EXPO = new Cubic(1.0, 0.0, 0.0, 1.0); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. This curve can be imagined as [#EASE_IN_CIRC] as the first /// half, and [#EASE_OUT_CIRC] as the second. /// /// Like [#EASE_IN_OUT_EXPO], this curve is fairly dramatic and will reduce /// the clarity of an animation if not given a longer duration. /// /// Compared to [#EASE_IN_OUT_EXPO], this curve is slightly steeper. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_out_circ.mp4 public static final Interpolator EASE_IN_OUT_CIRC = new Cubic(0.785, 0.135, 0.15, 0.86); /// A cubic animation curve that starts slowly, speeds up shortly thereafter, /// and then ends slowly. This curve can be imagined as a steeper version of /// [#EASE_IN_OUT_CUBIC]. /// /// The result is a more emphasized eased curve when choosing a curve for a /// widget whose initial and final positions are both within the viewport. /// /// Compared to [#EASE_IN_OUT_CUBIC], this curve is slightly steeper. /// /// @see curve_ease_in_out_cubic_emphasized.mp4 public static final Interpolator EASE_IN_OUT_CUBIC_EMPHASIZED = new ThreePointCubic( new Offset(0.05, 0), new Offset(0.133333, 0.06), new Offset(0.166666, 0.4), new Offset(0.208333, 0.82), new Offset(0.25, 1) ); /// A cubic animation curve that starts slowly, speeds up, and then ends /// slowly. This curve can be imagined as [#EASE_IN_BACK] as the first /// half, and [#EASE_OUT_BACK] as the second. /// /// Since two curves are used as a basis for this curve, the resulting /// animation will overshoot its bounds twice before reaching its end - first /// by exceeding its lower bound, then exceeding its upper bound and finally /// descending to its final position. /// /// Derived from Robert Penner’s easing functions. /// /// @see curve_ease_in_out_back.mp4 public static final Interpolator EASE_IN_OUT_BACK = new Cubic(0.68, -0.55, 0.265, 1.55); /// A curve that starts quickly and eases into its final position. /// /// Over the course of the animation, the object spends more time near its /// final destination. As a result, the user isn’t left waiting for the /// animation to finish, and the negative effects of motion are minimized. /// /// @see curve_fast_out_slow_in.mp4 public static final Interpolator FAST_OUT_SLOW_IN = new Cubic(0.4, 0.0, 0.2, 1.0); /// A cubic animation curve that starts quickly, slows down, and then ends /// quickly. /// /// @see curve_slow_middle.mp4 public static final Interpolator SLOW_MIDDLE = new Cubic(0.15, 0.85, 0.85, 0.15); private static double bounce(double t) { if (t < 1.0 / 2.75) { return 7.5625 * t * t; } else if (t < 2 / 2.75) { t -= 1.5 / 2.75; return 7.5625 * t * t + 0.75; } else if (t < 2.5 / 2.75) { t -= 2.25 / 2.75; return 7.5625 * t * t + 0.9375; } t -= 2.625 / 2.75; return 7.5625 * t * t + 0.984375; } /// An oscillating curve that grows in magnitude. /// /// @see curve_bounce_in.mp4 public static final Interpolator BOUNCE_IN = new Interpolator() { @Override protected double curve(double t) { return 1.0 - bounce(1.0 - t); } }; /// An oscillating curve that first grows and then shrink in magnitude. /// /// @see curve_bounce_out.mp4 public static final Interpolator BOUNCE_OUT = new Interpolator() { @Override protected double curve(double t) { return bounce(t); } }; /// An oscillating curve that first grows and then shrink in magnitude. /// /// @see curve_bounce_in_out.mp4 public static final Interpolator BOUNCE_IN_OUT = new Interpolator() { @Override protected double curve(double t) { if (t < 0.5) { return (1.0 - bounce(1.0 - t * 2.0)) * 0.5; } else { return bounce(t * 2.0 - 1.0) * 0.5 + 0.5; } } }; private static final double PERIOD = 0.4; /// An oscillating curve that grows in magnitude while overshooting its bounds. /// /// @see curve_elastic_in.mp4 public static final Interpolator ELASTIC_IN = new Interpolator() { @Override protected double curve(double t) { final double s = PERIOD / 4.0; t = t - 1.0; return -Math.pow(2.0, 10.0 * t) * Math.sin((t - s) * (Math.PI * 2.0) / PERIOD); } }; /// An oscillating curve that shrinks in magnitude while overshooting its bounds. /// /// @see curve_elastic_out.mp4 public static Interpolator ELASTIC_OUT = new Interpolator() { @Override protected double curve(double t) { final double s = PERIOD / 4.0; return Math.pow(2.0, -10 * t) * Math.sin((t - s) * (Math.PI * 2.0) / PERIOD) + 1.0; } }; /// An oscillating curve that grows and then shrinks in magnitude while overshooting its bounds. /// /// @see curve_elastic_in_out.mp4 public static Interpolator ELASTIC_IN_OUT = new Interpolator() { @Override @SuppressWarnings("DuplicateExpressions") protected double curve(double t) { final double s = PERIOD / 4.0; t = 2.0 * t - 1.0; if (t < 0.0) { return -0.5 * Math.pow(2.0, 10.0 * t) * Math.sin((t - s) * (Math.PI * 2.0) / PERIOD); } else { return Math.pow(2.0, -10.0 * t) * Math.sin((t - s) * (Math.PI * 2.0) / PERIOD) * 0.5 + 1.0; } } }; /// A cubic polynomial mapping of the unit interval. private static final class Cubic extends Interpolator { private static final double CUBIC_ERROR_BOUND = 0.001; /// The x coordinate of the first control point. /// /// The line through the point (0, 0) and the first control point is tangent /// to the curve at the point (0, 0). private final double a; /// The y coordinate of the first control point. /// /// The line through the point (0, 0) and the first control point is tangent /// to the curve at the point (0, 0). private final double b; /// The x coordinate of the second control point. /// /// The line through the point (1, 1) and the second control point is tangent /// to the curve at the point (1, 1). private final double c; /// The y coordinate of the second control point. /// /// The line through the point (1, 1) and the second control point is tangent /// to the curve at the point (1, 1). private final double d; private Cubic(double a, double b, double c, double d) { this.a = a; this.b = b; this.c = c; this.d = d; } double _evaluateCubic(double a, double b, double m) { return 3 * a * (1 - m) * (1 - m) * m + 3 * b * (1 - m) * m * m + m * m * m; } @Override protected double curve(double t) { double start = 0.0; double end = 1.0; while (true) { final double midpoint = (start + end) / 2; final double estimate = _evaluateCubic(a, c, midpoint); if (Math.abs(t - estimate) < CUBIC_ERROR_BOUND) { return _evaluateCubic(b, d, midpoint); } if (estimate < t) { start = midpoint; } else { end = midpoint; } } } @Override public boolean equals(Object o) { return o instanceof Cubic cubic && this.a == cubic.a && this.b == cubic.b && this.c == cubic.c && this.d == cubic.d; } @Override public int hashCode() { return Objects.hash(a, b, c, d); } @Override public String toString() { return "Cubic[a=%s, b=%s, c=%s, d=%s]".formatted(a, b, c, d); } } private record Offset(double dx, double dy) { } private static final class ThreePointCubic extends Interpolator { /// The coordinates of the first control point of the first curve. /// /// The line through the point (0, 0) and this control point is tangent to the /// curve at the point (0, 0). private final Offset a1; /// The coordinates of the second control point of the first curve. /// /// The line through the [#midpoint] and this control point is tangent to the /// curve approaching the [#midpoint]. private final Offset b1; /// The coordinates of the middle shared point. /// /// The curve will go through this point. If the control points surrounding /// this middle point ([#b1], and [#a2]) are not colinear with this point, then /// the curve's derivative will have a discontinuity (a cusp) at this point. private final Offset midpoint; /// The coordinates of the first control point of the second curve. /// /// The line through the [#midpoint] and this control point is tangent to the /// curve approaching the [#midpoint]. private final Offset a2; /// The coordinates of the second control point of the second curve. /// /// The line through the point (1, 1) and this control point is tangent to the /// curve at (1, 1). private final Offset b2; /// Creates two cubic curves that share a common control point. /// /// Rather than creating a new instance, consider using one of the common /// three-point cubic curves in [Interpolator]. /// /// The arguments correspond to the control points for the two curves, /// including the [#midpoint], but do not include the two implied end points at /// (0,0) and (1,1), which are fixed. private ThreePointCubic(Offset a1, Offset b1, Offset midpoint, Offset a2, Offset b2) { this.a1 = a1; this.b1 = b1; this.midpoint = midpoint; this.a2 = a2; this.b2 = b2; } @Override protected double curve(double t) { final boolean firstCurve = t < midpoint.dx; final double scaleX = firstCurve ? midpoint.dx : 1.0 - midpoint.dx; final double scaleY = firstCurve ? midpoint.dy : 1.0 - midpoint.dy; final double scaledT = (t - (firstCurve ? 0.0 : midpoint.dx)) / scaleX; if (firstCurve) { return new Cubic( a1.dx / scaleX, a1.dy / scaleY, b1.dx / scaleX, b1.dy / scaleY ).curve(scaledT) * scaleY; } else { return new Cubic( (a2.dx - midpoint.dx) / scaleX, (a2.dy - midpoint.dy) / scaleY, (b2.dx - midpoint.dx) / scaleX, (b2.dy - midpoint.dy) / scaleY ).curve(scaledT) * scaleY + midpoint.dy; } } @Override public boolean equals(Object o) { return o instanceof ThreePointCubic that && a1.equals(that.a1) && b1.equals(that.b1) && midpoint.equals(that.midpoint) && a2.equals(that.a2) && b2.equals(that.b2); } @Override public int hashCode() { return Objects.hash(a1, b1, midpoint, a2, b2); } @Override public String toString() { return "ThreePointCubic[a1=%s, b1=%s, midpoint=%s, a2=%s, b2=%s]".formatted(a1, b1, midpoint, a2, b2); } } //endregion Curves // region Durations /// The short1 duration (50ms) in the Material specification. public static final Duration SHORT1 = Duration.millis(50); /// The short2 duration (100ms) in the Material specification. public static final Duration SHORT2 = Duration.millis(100); /// The short3 duration (150ms) in the Material specification. public static final Duration SHORT3 = Duration.millis(150); /// The short4 duration (200ms) in the Material specification. public static final Duration SHORT4 = Duration.millis(200); /// The medium1 duration (250ms) in the Material specification. public static final Duration MEDIUM1 = Duration.millis(250); /// The medium2 duration (300ms) in the Material specification. public static final Duration MEDIUM2 = Duration.millis(300); /// The medium3 duration (350ms) in the Material specification. public static final Duration MEDIUM3 = Duration.millis(350); /// The medium4 duration (400ms) in the Material specification. public static final Duration MEDIUM4 = Duration.millis(400); /// The long1 duration (450ms) in the Material specification. public static final Duration LONG1 = Duration.millis(450); /// The long2 duration (500ms) in the Material specification. public static final Duration LONG2 = Duration.millis(500); /// The long3 duration (550ms) in the Material specification. public static final Duration LONG3 = Duration.millis(550); /// The long4 duration (600ms) in the Material specification. public static final Duration LONG4 = Duration.millis(600); /// The extralong1 duration (700ms) in the Material specification. public static final Duration EXTRA_LONG1 = Duration.millis(700); /// The extralong2 duration (800ms) in the Material specification. public static final Duration EXTRA_LONG2 = Duration.millis(800); /// The extralong3 duration (900ms) in the Material specification. public static final Duration EXTRA_LONG3 = Duration.millis(900); /// The extralong4 duration (1000ms) in the Material specification. public static final Duration EXTRA_LONG4 = Duration.millis(1000); // endregion Durations private Motion() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/animation/TransitionPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.animation; import javafx.animation.Animation; import javafx.animation.Interpolator; import javafx.application.Platform; import javafx.scene.CacheHint; import javafx.scene.Node; import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.util.Duration; import org.jackhuang.hmcl.ui.FXUtils; import org.jetbrains.annotations.Nullable; public class TransitionPane extends StackPane { private Node currentNode; public TransitionPane() { FXUtils.setOverflowHidden(this); } public Node getCurrentNode() { return currentNode; } public final void setContent(Node newView, AnimationProducer transition) { setContent(newView, transition, Motion.SHORT4); } public final void setContent(Node newView, AnimationProducer transition, Duration duration) { setContent(newView, transition, duration, Motion.EASE); } public void setContent(Node newView, AnimationProducer transition, Duration duration, Interpolator interpolator) { Node previousNode = currentNode != newView && getWidth() > 0 && getHeight() > 0 ? currentNode : null; currentNode = newView; if (!AnimationUtils.isAnimationEnabled() || previousNode == null || transition == ContainerAnimations.NONE) { AnimationUtils.reset(newView, true); getChildren().setAll(newView); return; } getChildren().setAll(previousNode, newView); setMouseTransparent(true); transition.init(this, previousNode, newView); CacheHint cacheHint = newView instanceof Cacheable cacheable ? cacheable.getCacheHint(transition) : null; if (cacheHint != null) { newView.setCache(true); newView.setCacheHint(cacheHint); } // runLater or "init" will not work Platform.runLater(() -> { Animation newAnimation = transition.animate( this, previousNode, newView, duration, interpolator); newAnimation.setOnFinished(e -> { setMouseTransparent(false); if (previousNode != currentNode) { getChildren().remove(previousNode); } if (cacheHint != null) { newView.setCache(false); } }); FXUtils.playAnimation(this, "transition_pane", newAnimation); }); } public interface AnimationProducer { default void init(TransitionPane container, Node previousNode, Node nextNode) { AnimationUtils.reset(previousNode, true); AnimationUtils.reset(nextNode, false); } Animation animate(Pane container, Node previousNode, Node nextNode, Duration duration, Interpolator interpolator); default @Nullable TransitionPane.AnimationProducer opposite() { return null; } } /// Marks a node as cacheable as a bitmap during animation. public interface Cacheable { /// @return the [cache hint][CacheHint] to use when caching this node during the given animation, /// or `null` to not cache it. default @Nullable CacheHint getCacheHint(AnimationProducer animationProducer) { // https://github.com/HMCL-dev/HMCL/issues/4789 return animationProducer == ContainerAnimations.SLIDE_UP_FADE_IN ? CacheHint.SPEED : null; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListBox.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.collections.ObservableList; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.ScrollPane; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Pane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.SVGContainer; import org.jackhuang.hmcl.ui.animation.Motion; import java.util.function.Consumer; public class AdvancedListBox extends ScrollPane { private final VBox container = new VBox(); { setContent(container); FXUtils.smoothScrolling(this); setFitToHeight(true); setFitToWidth(true); setHbarPolicy(ScrollBarPolicy.NEVER); setVbarPolicy(ScrollBarPolicy.NEVER); container.getStyleClass().add("advanced-list-box-content"); this.addEventFilter(MouseEvent.MOUSE_ENTERED, event -> { if (container.getHeight() > getHeight()) setVbarPolicy(ScrollBarPolicy.AS_NEEDED); }); this.addEventFilter(MouseEvent.MOUSE_EXITED, event -> setVbarPolicy(ScrollBarPolicy.NEVER)); } public AdvancedListBox add(Node child) { if (child instanceof Pane || child instanceof AdvancedListItem) container.getChildren().add(child); else { StackPane pane = new StackPane(); pane.getStyleClass().add("advanced-list-box-item"); pane.getChildren().setAll(child); container.getChildren().add(pane); } return this; } private AdvancedListItem createNavigationDrawerItem(String title, SVG leftGraphic) { AdvancedListItem item = new AdvancedListItem(); item.getStyleClass().add("navigation-drawer-item"); item.setTitle(title); if (leftGraphic != null) { item.setLeftIcon(leftGraphic); } return item; } public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Runnable onAction) { return addNavigationDrawerItem(title, leftGraphic, onAction, null); } public AdvancedListBox addNavigationDrawerItem(String title, SVG leftGraphic, Runnable onAction, Consumer initializer) { AdvancedListItem item = createNavigationDrawerItem(title, leftGraphic); if (onAction != null) { item.setOnAction(e -> onAction.run()); } if (initializer != null) { initializer.accept(item); } return add(item); } public AdvancedListBox addNavigationDrawerTab(TabHeader tabHeader, TabControl.Tab tab, String title, 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, SVG unselectedGraphic, SVG selectedGraphic) { AdvancedListItem item = createNavigationDrawerItem(title, null); item.activeProperty().bind(tabHeader.getSelectionModel().selectedItemProperty().isEqualTo(tab)); item.setOnAction(e -> tabHeader.select(tab)); var leftGraphic = new SVGContainer(item.isActive() ? selectedGraphic : unselectedGraphic, AdvancedListItem.LEFT_ICON_SIZE); leftGraphic.setMouseTransparent(true); AdvancedListItem.setAlignment(leftGraphic, Pos.CENTER); AdvancedListItem.setMargin(leftGraphic, AdvancedListItem.LEFT_ICON_MARGIN); FXUtils.onChange(item.activeProperty(), active -> leftGraphic.setIcon(active ? selectedGraphic : unselectedGraphic, Motion.SHORT4)); item.setLeftGraphic(leftGraphic); return add(item); } public AdvancedListBox add(int index, Node child) { if (child instanceof Pane || child instanceof AdvancedListItem) container.getChildren().add(index, child); else { StackPane pane = new StackPane(); pane.getStyleClass().add("advanced-list-box-item"); pane.getChildren().setAll(child); container.getChildren().add(index, pane); } return this; } public AdvancedListBox remove(Node child) { container.getChildren().remove(indexOf(child)); return this; } public int indexOf(Node child) { if (child instanceof Pane) { return container.getChildren().indexOf(child); } else { for (int i = 0; i < container.getChildren().size(); ++i) { Node node = container.getChildren().get(i); if (node instanceof StackPane) { ObservableList list = ((StackPane) node).getChildren(); if (list.size() == 1 && list.get(0) == child) return i; } } return -1; } } public AdvancedListBox startCategory(String category) { return add(new ClassTitle(category)); } public void setSpacing(double spacing) { container.setSpacing(spacing); } public void clear() { container.getChildren().clear(); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.*; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.Skin; import javafx.scene.layout.BorderPane; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; public class AdvancedListItem extends Control { public static final double LEFT_GRAPHIC_SIZE = 32; public static final double LEFT_ICON_SIZE = 20; public static final Insets LEFT_ICON_MARGIN = new Insets(0, 6, 0, 6); public static void setMargin(Node graphic, Insets margin) { BorderPane.setMargin(graphic, margin); } public static void setAlignment(Node graphic, Pos alignment) { BorderPane.setAlignment(graphic, alignment); } public AdvancedListItem() { getStyleClass().add("advanced-list-item"); FXUtils.onClicked(this, () -> fireEvent(new ActionEvent())); } private final ObjectProperty leftGraphic = new SimpleObjectProperty<>(this, "leftGraphic"); public ObjectProperty leftGraphicProperty() { return leftGraphic; } public Node getLeftGraphic() { return leftGraphic.get(); } public void setLeftGraphic(Node leftGraphic) { this.leftGraphic.set(leftGraphic); } public void setLeftIcon(SVG svg) { Node icon = svg.createIcon(LEFT_ICON_SIZE); icon.setMouseTransparent(true); BorderPane.setMargin(icon, LEFT_ICON_MARGIN); BorderPane.setAlignment(icon, Pos.CENTER); leftGraphicProperty().set(icon); } private final ObjectProperty rightGraphic = new SimpleObjectProperty<>(this, "rightGraphic"); public ObjectProperty rightGraphicProperty() { return rightGraphic; } public Node getRightGraphic() { return rightGraphic.get(); } public void setRightGraphic(Node rightGraphic) { this.rightGraphic.set(rightGraphic); } public void setRightAction(SVG icon, Runnable action) { var button = FXUtils.newToggleButton4(icon, 14); button.setOnAction(e -> { action.run(); e.consume(); }); setAlignment(button, Pos.CENTER); setRightGraphic(button); } private final StringProperty title = new SimpleStringProperty(this, "title"); public StringProperty titleProperty() { return title; } public String getTitle() { return title.get(); } public void setTitle(String title) { this.title.set(title); } private final StringProperty subtitle = new SimpleStringProperty(this, "subtitle"); public StringProperty subtitleProperty() { return subtitle; } public String getSubtitle() { return subtitle.get(); } public void setSubtitle(String subtitle) { this.subtitle.set(subtitle); } private final BooleanProperty active = new SimpleBooleanProperty(this, "active"); public BooleanProperty activeProperty() { return active; } public boolean isActive() { return active.get(); } public void setActive(boolean active) { this.active.set(active); } private final ObjectProperty> onAction = new SimpleObjectProperty<>(this, "onAction") { @Override protected void invalidated() { setEventHandler(ActionEvent.ACTION, get()); } }; public final ObjectProperty> onActionProperty() { return onAction; } public final void setOnAction(EventHandler value) { onActionProperty().set(value); } public final EventHandler getOnAction() { return onActionProperty().get(); } @Override protected Skin createDefaultSkin() { return new AdvancedListItemSkin(this); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/AdvancedListItemSkin.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.css.PseudoClass; import javafx.scene.control.SkinBase; import javafx.scene.layout.BorderPane; import org.jackhuang.hmcl.ui.FXUtils; public class AdvancedListItemSkin extends SkinBase { private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); public AdvancedListItemSkin(AdvancedListItem skinnable) { super(skinnable); FXUtils.onChangeAndOperate(skinnable.activeProperty(), active -> { skinnable.pseudoClassStateChanged(SELECTED, active); }); BorderPane root = new BorderPane(); root.getStyleClass().add("container"); root.setPickOnBounds(false); RipplerContainer container = new RipplerContainer(root); TwoLineListItem item = new TwoLineListItem(); root.setCenter(item); item.setMouseTransparent(true); item.titleProperty().bind(skinnable.titleProperty()); item.subtitleProperty().bind(skinnable.subtitleProperty()); root.leftProperty().bind(skinnable.leftGraphicProperty()); root.rightProperty().bind(skinnable.rightGraphicProperty()); getChildren().setAll(container); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ClassTitle.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.scene.Node; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.shape.Rectangle; import javafx.scene.text.Text; import org.jackhuang.hmcl.util.Lang; /** * @author huangyuhui */ public class ClassTitle extends StackPane { private final Node content; public ClassTitle(String text) { this(new Text(text)); } public ClassTitle(Node content) { this.content = content; VBox vbox = new VBox(); vbox.getChildren().addAll(content); Rectangle rectangle = new Rectangle(); rectangle.widthProperty().bind(vbox.widthProperty()); rectangle.setHeight(1.0); vbox.getChildren().add(rectangle); getChildren().setAll(vbox); getStyleClass().add("class-title"); } public ClassTitle(String text, Node rightNode) { this(Lang.apply(new BorderPane(), borderPane -> { borderPane.setLeft(Lang.apply(new VBox(), vBox -> vBox.getChildren().setAll(new Text(text)))); borderPane.setRight(rightNode); })); } public Node getContent() { return content; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentList.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.InvalidationListener; import javafx.beans.binding.Bindings; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Orientation; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.Label; import javafx.scene.layout.*; import org.jackhuang.hmcl.util.javafx.MappedObservableList; public class ComponentList extends Control implements NoPaddingComponent { public ComponentList() { getStyleClass().add("options-list"); } private final ObservableList content = FXCollections.observableArrayList(); public ObservableList getContent() { return content; } @Override public Orientation getContentBias() { return Orientation.HORIZONTAL; } @Override protected javafx.scene.control.Skin createDefaultSkin() { return new Skin(this); } private static final class Skin extends ControlSkinBase { private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first"); private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last"); private final ObservableList list; Skin(ComponentList control) { super(control); list = MappedObservableList.create(control.getContent(), node -> { Pane wrapper; if (node instanceof ComponentSublist sublist) { sublist.getStyleClass().remove("options-list"); sublist.getStyleClass().add("options-sublist"); wrapper = new ComponentSublistWrapper(sublist); } else { wrapper = new StackPane(node); } wrapper.getStyleClass().add("options-list-item"); if (node.getProperties().get("ComponentList.vgrow") instanceof Priority priority) { VBox.setVgrow(wrapper, priority); } if (node instanceof NoPaddingComponent || node.getProperties().containsKey("ComponentList.noPadding")) { wrapper.getStyleClass().add("no-padding"); } return wrapper; }); updateStyle(); list.addListener((InvalidationListener) o -> updateStyle()); VBox vbox = new VBox(); vbox.setFillWidth(true); Bindings.bindContent(vbox.getChildren(), list); node = vbox; } private Node prevFirstItem; private Node prevLastItem; private void updateStyle() { Node firstItem; Node lastItem; if (list.isEmpty()) { firstItem = null; lastItem = null; } else { firstItem = list.get(0); lastItem = list.get(list.size() - 1); } if (firstItem != prevFirstItem) { if (prevFirstItem != null) prevFirstItem.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, false); if (firstItem != null) firstItem.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, true); prevFirstItem = firstItem; } if (lastItem != prevLastItem) { if (prevLastItem != null) prevLastItem.pseudoClassStateChanged(PSEUDO_CLASS_LAST, false); if (lastItem != null) lastItem.pseudoClassStateChanged(PSEUDO_CLASS_LAST, true); prevLastItem = lastItem; } } } public static Node createComponentListTitle(String title) { HBox node = new HBox(); node.setAlignment(Pos.CENTER_LEFT); node.setPadding(new Insets(8, 0, 0, 0)); { Label advanced = new Label(title); node.getChildren().setAll(advanced); } return node; } public static void setVgrow(Node node, Priority priority) { node.getProperties().put("ComponentList.vgrow", priority); } public static void setNoPadding(Node node) { node.getProperties().put("ComponentList.noPadding", true); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublist.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.*; import javafx.scene.Node; import java.util.List; import java.util.function.Supplier; public class ComponentSublist extends ComponentList { Supplier> lazyInitializer; public ComponentSublist() { super(); } public ComponentSublist(Supplier> lazyInitializer) { this.lazyInitializer = lazyInitializer; } void doLazyInit() { if (lazyInitializer != null) { this.getContent().setAll(lazyInitializer.get()); setNeedsLayout(true); lazyInitializer = null; } } private final StringProperty title = new SimpleStringProperty(this, "title", "Group"); public StringProperty titleProperty() { return title; } public String getTitle() { return titleProperty().get(); } public void setTitle(String title) { titleProperty().set(title); } private StringProperty subtitle; public StringProperty subtitleProperty() { if (subtitle == null) subtitle = new SimpleStringProperty(this, "subtitle", ""); return subtitle; } public String getSubtitle() { return subtitleProperty().get(); } public void setSubtitle(String subtitle) { subtitleProperty().set(subtitle); } private boolean hasSubtitle = false; public boolean isHasSubtitle() { return hasSubtitle; } public void setHasSubtitle(boolean hasSubtitle) { this.hasSubtitle = hasSubtitle; } private Node headerLeft; public Node getHeaderLeft() { return headerLeft; } public void setHeaderLeft(Node headerLeft) { this.headerLeft = headerLeft; } private Node headerRight; public Node getHeaderRight() { return headerRight; } public void setHeaderRight(Node headerRight) { this.headerRight = headerRight; } private boolean componentPadding = true; public boolean hasComponentPadding() { return componentPadding; } public void setComponentPadding(boolean componentPadding) { this.componentPadding = componentPadding; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ComponentSublistWrapper.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.animation.*; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.input.MouseButton; import javafx.scene.input.MouseEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import javafx.util.Duration; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.Motion; /// @author Glavo final class ComponentSublistWrapper extends VBox implements NoPaddingComponent { private VBox container; private Animation expandAnimation; private boolean expanded = false; ComponentSublistWrapper(ComponentSublist sublist) { boolean noPadding = !sublist.hasComponentPadding(); this.getStyleClass().add("options-sublist-wrapper"); Node expandIcon = SVG.KEYBOARD_ARROW_DOWN.createIcon(20); expandIcon.getStyleClass().add("expand-icon"); expandIcon.setMouseTransparent(true); VBox labelVBox = new VBox(); labelVBox.setMouseTransparent(true); labelVBox.setAlignment(Pos.CENTER_LEFT); Node leftNode = sublist.getHeaderLeft(); if (leftNode == null) { Label label = new Label(); label.textProperty().bind(sublist.titleProperty()); label.getStyleClass().add("title-label"); labelVBox.getChildren().add(label); if (sublist.isHasSubtitle()) { Label subtitleLabel = new Label(); subtitleLabel.textProperty().bind(sublist.subtitleProperty()); subtitleLabel.getStyleClass().add("subtitle-label"); subtitleLabel.textFillProperty().bind(Themes.colorSchemeProperty().getOnSurfaceVariant()); labelVBox.getChildren().add(subtitleLabel); } } else { labelVBox.getChildren().setAll(leftNode); } HBox header = new HBox(); header.setSpacing(12); header.getChildren().add(labelVBox); header.setPadding(new Insets(10, 16, 10, 16)); header.setAlignment(Pos.CENTER_LEFT); HBox.setHgrow(labelVBox, Priority.ALWAYS); Node rightNode = sublist.getHeaderRight(); if (rightNode != null) header.getChildren().add(rightNode); header.getChildren().add(expandIcon); RipplerContainer headerRippler = new RipplerContainer(header); this.getChildren().add(headerRippler); headerRippler.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { if (event.getButton() != MouseButton.PRIMARY) return; event.consume(); if (expandAnimation != null && expandAnimation.getStatus() == Animation.Status.RUNNING) { expandAnimation.stop(); } boolean expanded = !this.expanded; this.expanded = expanded; if (expanded) { sublist.doLazyInit(); if (container == null) { this.container = new VBox(); if (!noPadding) { container.setPadding(new Insets(8, 16, 10, 16)); } FXUtils.setLimitHeight(container, 0); FXUtils.setOverflowHidden(container); container.getChildren().setAll(sublist); ComponentSublistWrapper.this.getChildren().add(container); this.applyCss(); } this.layout(); } Platform.runLater(() -> { // FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius. double contentHeight = expanded ? (sublist.prefHeight(sublist.getWidth()) + (noPadding ? 4 : 8 + 10)) : 0; double targetRotate = expanded ? -180 : 0; if (AnimationUtils.isAnimationEnabled()) { double currentRotate = expandIcon.getRotate(); Duration duration = Motion.LONG2.multiply(Math.abs(currentRotate - targetRotate) / 180.0); Interpolator interpolator = Motion.EASE_IN_OUT_CUBIC_EMPHASIZED; expandAnimation = new Timeline( new KeyFrame(duration, new KeyValue(container.minHeightProperty(), contentHeight, interpolator), new KeyValue(container.maxHeightProperty(), contentHeight, interpolator), new KeyValue(expandIcon.rotateProperty(), targetRotate, interpolator)) ); expandAnimation.play(); } else { container.setMinHeight(contentHeight); container.setMaxHeight(contentHeight); expandIcon.setRotate(targetRotate); } }); }); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ControlSkinBase.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2022 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.Skin; import java.util.Objects; public abstract class ControlSkinBase implements Skin { private final C control; protected Node node; /** * Constructor for all SkinBase instances. * * @param control The control for which this Skin should attach to. */ protected ControlSkinBase(C control) { this.control = control; } @Override public C getSkinnable() { return control; } @Override public Node getNode() { Objects.requireNonNull(node); return node; } @Override public void dispose() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogAware.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import org.jackhuang.hmcl.ui.Controllers; /** * @author yushijinhun * @see Controllers#dialog(javafx.scene.layout.Region) */ public interface DialogAware { default void onDialogShown() { } default void onDialogClosed() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogCloseEvent.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.event.Event; import javafx.event.EventTarget; import javafx.event.EventType; import javafx.scene.layout.Region; import org.jackhuang.hmcl.ui.Controllers; /** * Indicates a close operation on the dialog. * * @author yushijinhun * @see Controllers#dialog(Region) */ public class DialogCloseEvent extends Event { public static final EventType CLOSE = new EventType<>("DIALOG_CLOSE"); public DialogCloseEvent() { super(CLOSE); } public DialogCloseEvent(Object source, EventTarget target) { super(source, target, CLOSE); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DialogPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXProgressBar; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.layout.StackPane; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class DialogPane extends JFXDialogLayout { private final StringProperty title = new SimpleStringProperty(); private final BooleanProperty valid = new SimpleBooleanProperty(true); protected final SpinnerPane acceptPane = new SpinnerPane(); protected final JFXButton cancelButton = new JFXButton(); protected final Label warningLabel = new Label(); private final JFXProgressBar progressBar = new JFXProgressBar(); public DialogPane() { Label titleLabel = new Label(); titleLabel.textProperty().bind(title); setHeading(titleLabel); getChildren().add(progressBar); progressBar.setVisible(false); StackPane.setMargin(progressBar, new Insets(-24.0D, -24.0D, -16.0D, -24.0D)); StackPane.setAlignment(progressBar, Pos.TOP_CENTER); progressBar.setMaxWidth(Double.MAX_VALUE); JFXButton acceptButton = new JFXButton(i18n("button.ok")); acceptButton.setOnAction(e -> onAccept()); acceptButton.disableProperty().bind(valid.not()); acceptButton.getStyleClass().add("dialog-accept"); acceptPane.getStyleClass().add("small-spinner-pane"); acceptPane.setContent(acceptButton); cancelButton.setText(i18n("button.cancel")); cancelButton.setOnAction(e -> onCancel()); cancelButton.getStyleClass().add("dialog-cancel"); onEscPressed(this, cancelButton::fire); setActions(warningLabel, acceptPane, cancelButton); } protected JFXProgressBar getProgressBar() { return progressBar; } public String getTitle() { return title.get(); } public StringProperty titleProperty() { return title; } public void setTitle(String title) { this.title.set(title); } public boolean isValid() { return valid.get(); } public BooleanProperty validProperty() { return valid; } public void setValid(boolean valid) { this.valid.set(valid); } protected void onCancel() { fireEvent(new DialogCloseEvent()); } protected void onAccept() { fireEvent(new DialogCloseEvent()); } protected void setLoading() { acceptPane.showSpinner(); warningLabel.setText(""); } protected void onSuccess() { acceptPane.hideSpinner(); fireEvent(new DialogCloseEvent()); } protected void onFailure(String msg) { acceptPane.hideSpinner(); warningLabel.setText(msg); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/DoubleValidator.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.NamedArg; import javafx.scene.control.TextInputControl; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; public class DoubleValidator extends ValidatorBase { private final boolean nullable; public DoubleValidator() { this(false); } public DoubleValidator(@NamedArg("nullable") boolean nullable) { this.nullable = nullable; } public DoubleValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) { super(message); this.nullable = nullable; } @Override protected void eval() { if (srcControl.get() instanceof TextInputControl) { evalTextInputField(); } } private void evalTextInputField() { TextInputControl textField = ((TextInputControl) srcControl.get()); if (StringUtils.isBlank(textField.getText())) hasErrors.set(!nullable); else hasErrors.set(Lang.toDoubleOrNull(textField.getText()) == null); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FileSelector.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXTextField; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Pos; import javafx.scene.layout.HBox; import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.util.io.FileUtils; import java.nio.file.Path; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class FileSelector extends HBox { private final StringProperty value = new SimpleStringProperty(); private String chooserTitle = i18n("selector.choose_file"); private boolean directory = false; private final ObservableList extensionFilters = FXCollections.observableArrayList(); public String getValue() { return value.get(); } public StringProperty valueProperty() { return value; } public void setValue(String value) { this.value.set(value); } public String getChooserTitle() { return chooserTitle; } public FileSelector setChooserTitle(String chooserTitle) { this.chooserTitle = chooserTitle; return this; } public boolean isDirectory() { return directory; } public FileSelector setDirectory(boolean directory) { this.directory = directory; return this; } public ObservableList getExtensionFilters() { return extensionFilters; } public FileSelector() { JFXTextField customField = new JFXTextField(); FXUtils.bindString(customField, valueProperty()); JFXButton selectButton = FXUtils.newToggleButton4(SVG.FOLDER_OPEN, 15); selectButton.setOnAction(e -> { if (directory) { DirectoryChooser chooser = new DirectoryChooser(); chooser.setTitle(chooserTitle); Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage())); if (dir != null) { String path = FileUtils.getAbsolutePath(dir); customField.setText(path); value.setValue(path); } } else { FileChooser chooser = new FileChooser(); chooser.getExtensionFilters().addAll(getExtensionFilters()); chooser.setTitle(chooserTitle); Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage())); if (file != null) { String path = FileUtils.getAbsolutePath(file); customField.setText(path); value.setValue(path); } } }); setAlignment(Pos.CENTER_LEFT); setSpacing(3); getChildren().addAll(customField, selectButton); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FloatScrollBarSkin.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.binding.Bindings; import javafx.beans.binding.NumberBinding; import javafx.geometry.Orientation; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.control.ScrollBar; import javafx.scene.control.Skin; import javafx.scene.layout.Region; import javafx.scene.shape.Rectangle; import org.jackhuang.hmcl.util.Lang; // Referenced in root.css @SuppressWarnings("unused") public class FloatScrollBarSkin implements Skin { private ScrollBar scrollBar; private Region group; private Rectangle track = new Rectangle(); private Rectangle thumb = new Rectangle(); public FloatScrollBarSkin(final ScrollBar scrollBar) { this.scrollBar = scrollBar; scrollBar.setPrefHeight(1e-18); scrollBar.setPrefWidth(1e-18); this.group = new Region() { Point2D dragStart; double preDragThumbPos; NumberBinding range = Bindings.subtract(scrollBar.maxProperty(), scrollBar.minProperty()); NumberBinding position = Bindings.divide(Bindings.subtract(scrollBar.valueProperty(), scrollBar.minProperty()), range); { // Children are added unmanaged because for some reason the height of the bar keeps changing // if they're managed in certain situations... not sure about the cause. getChildren().addAll(track, thumb); track.setManaged(false); track.getStyleClass().add("track"); thumb.setManaged(false); thumb.getStyleClass().add("thumb"); scrollBar.orientationProperty().addListener(obs -> setup()); setup(); thumb.setOnMousePressed(me -> { if (me.isSynthesized()) { // touch-screen events handled by Scroll handler me.consume(); return; } /* ** if max isn't greater than min then there is nothing to do here */ if (getSkinnable().getMax() > getSkinnable().getMin()) { dragStart = thumb.localToParent(me.getX(), me.getY()); double clampedValue = Lang.clamp(getSkinnable().getMin(), getSkinnable().getValue(), getSkinnable().getMax()); preDragThumbPos = (clampedValue - getSkinnable().getMin()) / (getSkinnable().getMax() - getSkinnable().getMin()); me.consume(); } }); thumb.setOnMouseDragged(me -> { if (me.isSynthesized()) { // touch-screen events handled by Scroll handler me.consume(); return; } /* ** if max isn't greater than min then there is nothing to do here */ if (getSkinnable().getMax() > getSkinnable().getMin()) { /* ** if the tracklength isn't greater then do nothing.... */ if (trackLength() > thumbLength()) { Point2D cur = thumb.localToParent(me.getX(), me.getY()); if (dragStart == null) { // we're getting dragged without getting a mouse press dragStart = thumb.localToParent(me.getX(), me.getY()); } double dragPos = getSkinnable().getOrientation() == Orientation.VERTICAL ? cur.getY() - dragStart.getY() : cur.getX() - dragStart.getX(); double position = preDragThumbPos + dragPos / (trackLength() - thumbLength()); if (!getSkinnable().isFocused() && getSkinnable().isFocusTraversable()) getSkinnable().requestFocus(); double newValue = (position * (getSkinnable().getMax() - getSkinnable().getMin())) + getSkinnable().getMin(); if (!Double.isNaN(newValue)) { getSkinnable().setValue(Lang.clamp(getSkinnable().getMin(), newValue, getSkinnable().getMax())); } } me.consume(); } }); } private double trackLength() { return getSkinnable().getOrientation() == Orientation.VERTICAL ? track.getHeight() : track.getWidth(); } private double thumbLength() { return getSkinnable().getOrientation() == Orientation.VERTICAL ? thumb.getHeight() : thumb.getWidth(); } private void setup() { track.widthProperty().unbind(); track.heightProperty().unbind(); if (scrollBar.getOrientation() == Orientation.HORIZONTAL) { track.relocate(0, -8); track.widthProperty().bind(scrollBar.widthProperty()); track.setHeight(8); } else { track.relocate(-8, 0); track.setWidth(8); track.heightProperty().bind(scrollBar.heightProperty()); } thumb.xProperty().unbind(); thumb.yProperty().unbind(); thumb.widthProperty().unbind(); thumb.heightProperty().unbind(); if (scrollBar.getOrientation() == Orientation.HORIZONTAL) { thumb.relocate(0, -8); thumb.widthProperty().bind(Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(scrollBar.widthProperty()))); thumb.setHeight(8); thumb.xProperty().bind(Bindings.subtract(scrollBar.widthProperty(), thumb.widthProperty()).multiply(position)); } else { thumb.relocate(-8, 0); thumb.setWidth(8); thumb.heightProperty().bind(Bindings.max(20, scrollBar.visibleAmountProperty().divide(range).multiply(scrollBar.heightProperty()))); thumb.yProperty().bind(Bindings.subtract(scrollBar.heightProperty(), thumb.heightProperty()).multiply(position)); } } @Override protected double computeMaxWidth(double height) { if (scrollBar.getOrientation() == Orientation.HORIZONTAL) { return Double.MAX_VALUE; } return 8; } @Override protected double computeMaxHeight(double width) { if (scrollBar.getOrientation() == Orientation.VERTICAL) { return Double.MAX_VALUE; } return 8; } }; } @Override public void dispose() { scrollBar = null; group = null; } @Override public Node getNode() { return group; } @Override public ScrollBar getSkinnable() { return scrollBar; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/FontComboBox.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import static javafx.collections.FXCollections.emptyObservableList; import static javafx.collections.FXCollections.observableList; import static javafx.collections.FXCollections.singletonObservableList; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXListCell; import javafx.beans.binding.Bindings; import javafx.scene.text.Font; public final class FontComboBox extends JFXComboBox { private boolean loaded = false; public FontComboBox() { setMinWidth(260); styleProperty().bind(Bindings.concat("-fx-font-family: \"", valueProperty(), "\"")); setCellFactory(listView -> new JFXListCell() { @Override public void updateItem(String item, boolean empty) { super.updateItem(item, empty); if (!empty) { setText(item); setGraphic(null); setStyle("-fx-font-family: \"" + item + "\""); } } }); itemsProperty().bind(BindingMapping.of(valueProperty()) .map(value -> value == null ? emptyObservableList() : singletonObservableList(value))); FXUtils.onClicked(this, () -> { if (loaded) return; itemsProperty().unbind(); setItems(observableList(Font.getFamilies())); loaded = true; }); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/HintPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import java.util.Locale; import java.util.function.Consumer; public class HintPane extends VBox { private final Text label = new Text(); private final StringProperty text = new SimpleStringProperty(this, "text"); private final TextFlow flow = new TextFlow(); public HintPane() { this(MessageDialogPane.MessageType.INFO); } public HintPane(MessageDialogPane.MessageType type) { setFillWidth(true); getStyleClass().addAll("hint", type.name().toLowerCase(Locale.ROOT)); HBox hbox = new HBox(type.getIcon().createIcon(16), new Text(type.getDisplayName())); hbox.setAlignment(Pos.CENTER_LEFT); hbox.setSpacing(2); flow.getChildren().setAll(label); getChildren().setAll(hbox, flow); label.textProperty().bind(text); VBox.setMargin(flow, new Insets(2, 2, 0, 2)); } public String getText() { return text.get(); } public StringProperty textProperty() { return text; } public void setText(String text) { this.text.set(text); } public void setSegment(String segment) { this.setSegment(segment, Controllers::onHyperlinkAction); } public void setSegment(String segment, Consumer hyperlinkAction) { flow.getChildren().setAll(FXUtils.parseSegment(segment, hyperlinkAction)); } public void setChildren(Node... children) { flow.getChildren().setAll(children); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.layout.HBox; public class IconedItem extends RipplerContainer { private Label label; public IconedItem(Node icon, String text) { this(icon); label.setText(text); } public IconedItem(Node icon) { super(createHBox(icon)); label = ((Label) lookup("#label")); getStyleClass().setAll("iconed-item"); } private static HBox createHBox(Node icon) { HBox hBox = new HBox(); if (icon != null) { icon.setMouseTransparent(true); hBox.getChildren().add(icon); } hBox.getStyleClass().add("iconed-item-container"); Label textLabel = new Label(); textLabel.setId("label"); textLabel.setMouseTransparent(true); hBox.getChildren().addAll(textLabel); return hBox; } public Label getLabel() { return label; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/IconedMenuItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXPopup; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; public final class IconedMenuItem extends IconedItem { public IconedMenuItem(SVG icon, String text, Runnable action, JFXPopup popup) { super(icon != null ? icon.createIcon(14) : null, text); getStyleClass().setAll("iconed-menu-item"); if (popup == null) { FXUtils.onClicked(this, action); } else { FXUtils.onClicked(this, () -> { action.run(); popup.hide(); }); } } public IconedMenuItem addTooltip(String tooltip) { FXUtils.installFastTooltip(this, tooltip); return this; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImageContainer.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.css.CssMetaData; import javafx.css.Styleable; import javafx.css.StyleableDoubleProperty; import javafx.css.StyleableProperty; import javafx.css.converter.SizeConverter; import javafx.geometry.Pos; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.StackPane; import javafx.scene.shape.Rectangle; import org.jackhuang.hmcl.ui.FXUtils; import java.util.ArrayList; import java.util.List; /// A custom ImageView with fixed size and corner radius support. public class ImageContainer extends StackPane { private static final String DEFAULT_STYLE_CLASS = "image-container"; private final ImageView imageView = new ImageView(); private final Rectangle clip = new Rectangle(); public ImageContainer(double size) { this(size, size); } public ImageContainer(double width, double height) { this.getStyleClass().add(DEFAULT_STYLE_CLASS); FXUtils.setLimitWidth(this, width); FXUtils.setLimitHeight(this, height); imageView.setPreserveRatio(true); FXUtils.limitSize(imageView, width, height); StackPane.setAlignment(imageView, Pos.CENTER); clip.setWidth(width); clip.setHeight(height); updateCornerRadius(getCornerRadius()); this.setClip(clip); this.getChildren().setAll(imageView); } private void updateCornerRadius(double radius) { clip.setArcWidth(radius); clip.setArcHeight(radius); } private static final double DEFAULT_CORNER_RADIUS = 6.0; private StyleableDoubleProperty cornerRadius; public StyleableDoubleProperty cornerRadiusProperty() { if (this.cornerRadius == null) { cornerRadius = new StyleableDoubleProperty() { @Override public Object getBean() { return ImageContainer.this; } @Override public String getName() { return "cornerRadius"; } @Override public CssMetaData getCssMetaData() { return StyleableProperties.CORNER_RADIUS; } @Override protected void invalidated() { updateCornerRadius(get()); } }; } return cornerRadius; } public double getCornerRadius() { return cornerRadius == null ? DEFAULT_CORNER_RADIUS : cornerRadius.get(); } public void setCornerRadius(double radius) { cornerRadiusProperty().set(radius); } public ObjectProperty imageProperty() { return imageView.imageProperty(); } public Image getImage() { return imageView.getImage(); } public void setImage(Image image) { imageView.setImage(image); } public BooleanProperty smoothProperty() { return imageView.smoothProperty(); } public boolean isSmooth() { return imageView.isSmooth(); } public void setSmooth(boolean smooth) { imageView.setSmooth(smooth); } @Override public List> getCssMetaData() { return StyleableProperties.STYLEABLES; } private static final class StyleableProperties { private static final CssMetaData CORNER_RADIUS = new CssMetaData<>("-jfx-corner-radius", SizeConverter.getInstance(), DEFAULT_CORNER_RADIUS) { @Override public boolean isSettable(ImageContainer control) { return control.cornerRadius == null || !control.cornerRadius.isBound(); } @Override public StyleableProperty getStyleableProperty(ImageContainer control) { return control.cornerRadiusProperty(); } }; private static final List> STYLEABLES; static { var styleables = new ArrayList<>(StackPane.getClassCssMetaData()); styleables.add(CORNER_RADIUS); STYLEABLES = List.copyOf(styleables); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/ImagePickerItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXButton; import javafx.beans.DefaultProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.image.Image; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @DefaultProperty("image") public final class ImagePickerItem extends BorderPane { private final ImageContainer imageContainer; private final StringProperty title = new SimpleStringProperty(this, "title"); private final ObjectProperty> onSelectButtonClicked = new SimpleObjectProperty<>(this, "onSelectButtonClicked"); private final ObjectProperty> onDeleteButtonClicked = new SimpleObjectProperty<>(this, "onDeleteButtonClicked"); private final ObjectProperty image = new SimpleObjectProperty<>(this, "image"); public ImagePickerItem() { imageContainer = new ImageContainer(32); imageContainer.setSmooth(false); JFXButton selectButton = FXUtils.newToggleButton4(SVG.EDIT, 20); selectButton.onActionProperty().bind(onSelectButtonClicked); JFXButton deleteButton = FXUtils.newToggleButton4(SVG.RESTORE, 20); deleteButton.onActionProperty().bind(onDeleteButtonClicked); FXUtils.installFastTooltip(selectButton, i18n("button.edit")); FXUtils.installFastTooltip(deleteButton, i18n("button.reset")); HBox hBox = new HBox(); hBox.getChildren().setAll(imageContainer, selectButton, deleteButton); hBox.setAlignment(Pos.CENTER_RIGHT); hBox.setSpacing(8); setRight(hBox); VBox vBox = new VBox(); Label label = new Label(); label.textProperty().bind(title); vBox.getChildren().setAll(label); vBox.setAlignment(Pos.CENTER_LEFT); setLeft(vBox); imageContainer.imageProperty().bind(image); } public String getTitle() { return title.get(); } public StringProperty titleProperty() { return title; } public void setTitle(String title) { this.title.set(title); } public EventHandler getOnSelectButtonClicked() { return onSelectButtonClicked.get(); } public ObjectProperty> onSelectButtonClickedProperty() { return onSelectButtonClicked; } public void setOnSelectButtonClicked(EventHandler onSelectButtonClicked) { this.onSelectButtonClicked.set(onSelectButtonClicked); } public EventHandler getOnDeleteButtonClicked() { return onDeleteButtonClicked.get(); } public ObjectProperty> onDeleteButtonClickedProperty() { return onDeleteButtonClicked; } public void setOnDeleteButtonClicked(EventHandler onDeleteButtonClicked) { this.onDeleteButtonClicked.set(onDeleteButtonClicked); } public Image getImage() { return image.get(); } public ObjectProperty imageProperty() { return image; } public void setImage(Image image) { this.image.set(image); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/InputDialogPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXTextField; import com.jfoenix.validation.base.ValidatorBase; import javafx.scene.control.Label; import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FutureCallback; import java.util.concurrent.CompletableFuture; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class InputDialogPane extends JFXDialogLayout implements DialogAware { private final CompletableFuture future = new CompletableFuture<>(); private final JFXTextField textField; private final Label lblCreationWarning; private final SpinnerPane acceptPane; private final JFXButton acceptButton; public InputDialogPane(String text, String initialValue, FutureCallback onResult, ValidatorBase... validators) { this(text, initialValue, onResult); if (validators != null && validators.length > 0) { textField.getValidators().addAll(validators); FXUtils.setValidateWhileTextChanged(textField, true); acceptButton.disableProperty().bind(textField.activeValidatorProperty().isNotNull()); } } public InputDialogPane(String text, String initialValue, FutureCallback onResult) { textField = new JFXTextField(initialValue); this.setHeading(new HBox(new Label(text))); this.setBody(new VBox(textField)); lblCreationWarning = new Label(); acceptPane = new SpinnerPane(); acceptPane.getStyleClass().add("small-spinner-pane"); acceptButton = new JFXButton(i18n("button.ok")); acceptButton.getStyleClass().add("dialog-accept"); acceptPane.setContent(acceptButton); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); cancelButton.getStyleClass().add("dialog-cancel"); this.setActions(lblCreationWarning, acceptPane, cancelButton); cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); acceptButton.setOnAction(e -> { acceptPane.showSpinner(); onResult.call(textField.getText(), new FutureCallback.ResultHandler() { @Override public void resolve() { acceptPane.hideSpinner(); future.complete(textField.getText()); fireEvent(new DialogCloseEvent()); } @Override public void reject(String reason) { acceptPane.hideSpinner(); lblCreationWarning.setText(reason); } }); }); textField.setOnAction(event -> acceptButton.fire()); onEscPressed(this, cancelButton::fire); } @Override public void onDialogShown() { textField.requestFocus(); } public CompletableFuture getCompletableFuture() { return future; } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXCheckBoxTableCell.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2025 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXCheckBox; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.scene.control.TableCell; import javafx.scene.control.TableColumn; import javafx.util.Callback; /// @author Glavo public final class JFXCheckBoxTableCell extends TableCell { public static Callback, TableCell> forTableColumn( final TableColumn column) { return list -> new JFXCheckBoxTableCell<>(); } private final JFXCheckBox checkBox = new JFXCheckBox(); private BooleanProperty booleanProperty; public JFXCheckBoxTableCell() { this.getStyleClass().add("jfx-checkbox-table-cell"); } @Override protected void updateItem(T item, boolean empty) { super.updateItem(item, empty); if (empty) { setText(null); setGraphic(null); checkBox.disableProperty().unbind(); } else { setGraphic(checkBox); if (booleanProperty != null) { checkBox.selectedProperty().unbindBidirectional(booleanProperty); } if (getTableColumn().getCellObservableValue(getIndex()) instanceof BooleanProperty obsValue) { booleanProperty = obsValue; checkBox.selectedProperty().bindBidirectional(booleanProperty); } checkBox.disableProperty().bind(Bindings.not( getTableView().editableProperty().and( getTableColumn().editableProperty()).and( editableProperty()) )); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXDialogPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.scene.Node; import javafx.scene.layout.StackPane; import java.util.ArrayList; import java.util.Optional; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class JFXDialogPane extends StackPane { private final ArrayList stack = new ArrayList<>(); public int size() { return stack.size(); } public Optional peek() { if (stack.isEmpty()) { return Optional.empty(); } else { return Optional.of(stack.get(stack.size() - 1)); } } public void push(Node node) { stack.add(node); getChildren().setAll(node); LOG.info(this + " " + stack); } public void pop(Node node) { boolean flag = stack.remove(node); if (stack.isEmpty()) getChildren().setAll(); else getChildren().setAll(stack.get(stack.size() - 1)); LOG.info(this + " " + stack + ", removed: " + flag + ", object: " + node); } public boolean isEmpty() { return stack.isEmpty(); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/JFXHyperlink.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.scene.control.Hyperlink; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; public final class JFXHyperlink extends Hyperlink { public JFXHyperlink(String text) { super(text); setGraphic(SVG.OPEN_IN_NEW.createIcon(16)); } public void setExternalLink(String externalLink) { this.setOnAction(e -> FXUtils.openLink(externalLink)); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButton.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.*; import javafx.event.ActionEvent; import javafx.event.EventHandler; import javafx.scene.Node; import javafx.scene.control.Label; import javafx.scene.control.OverrunStyle; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; /// @author Glavo public class LineButton extends LineButtonBase { private static final String DEFAULT_STYLE_CLASS = "line-button"; private static final int IDX_TRAILING_TEXT = IDX_TRAILING; private static final int IDX_TRAILING_ICON = IDX_TRAILING + 1; public static LineButton createNavigationButton() { var button = new LineButton(); button.setTrailingIcon(SVG.ARROW_FORWARD); return button; } public static LineButton createExternalLinkButton(String url) { var button = new LineButton(); button.setTrailingIcon(SVG.OPEN_IN_NEW); if (url != null) { button.setOnAction(event -> FXUtils.openLink(url)); } return button; } public LineButton() { getStyleClass().add(DEFAULT_STYLE_CLASS); container.setMouseTransparent(true); } private ObjectProperty> onAction; public ObjectProperty> onActionProperty() { if (onAction == null) { onAction = new ObjectPropertyBase<>() { @Override public Object getBean() { return LineButton.this; } @Override public String getName() { return "onAction"; } @Override protected void invalidated() { setEventHandler(ActionEvent.ACTION, get()); } }; } return onAction; } public EventHandler getOnAction() { return onActionProperty().get(); } public void setOnAction(EventHandler value) { onActionProperty().set(value); } private StringProperty trailingText; public StringProperty trailingTextProperty() { if (trailingText == null) { trailingText = new StringPropertyBase() { private Label trailingTextLabel; @Override public Object getBean() { return LineButton.this; } @Override public String getName() { return "trailingText"; } @Override protected void invalidated() { String message = get(); if (message != null && !message.isEmpty()) { if (trailingTextLabel == null) { trailingTextLabel = new Label(); trailingTextLabel.getStyleClass().add("trailing-label"); trailingTextLabel.setTextOverrun(OverrunStyle.CENTER_ELLIPSIS); } trailingTextLabel.setText(message); setNode(IDX_TRAILING_TEXT, trailingTextLabel); } else if (trailingTextLabel != null) { trailingTextLabel.setText(""); setNode(IDX_TRAILING_TEXT, null); } } }; } return trailingText; } public String getTrailingText() { return trailingText != null ? trailingText.get() : null; } public void setTrailingText(String trailingText) { trailingTextProperty().set(trailingText); } private ObjectProperty trailingIcon; public ObjectProperty trailingIconProperty() { if (trailingIcon == null) trailingIcon = new ObjectPropertyBase<>() { @Override public Object getBean() { return LineButton.this; } @Override public String getName() { return "trailingIcon"; } @Override protected void invalidated() { setNode(IDX_TRAILING_ICON, get()); } }; return trailingIcon; } public Node getTrailingIcon() { return trailingIcon != null ? trailingIcon.get() : null; } public void setTrailingIcon(Node trailingIcon) { trailingIconProperty().set(trailingIcon); } public void setTrailingIcon(SVG rightIcon) { setTrailingIcon(rightIcon, 20); } public void setTrailingIcon(SVG rightIcon, double size) { Node rightIconNode = rightIcon.createIcon(size); rightIconNode.getStyleClass().add("trailing-icon"); setTrailingIcon(rightIconNode); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineButtonBase.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.event.ActionEvent; import org.jackhuang.hmcl.ui.FXUtils; /// @author Glavo public abstract class LineButtonBase extends LineComponent { private static final String DEFAULT_STYLE_CLASS = "line-button-base"; protected final RipplerContainer ripplerContainer; public LineButtonBase() { this.getStyleClass().addAll(LineButtonBase.DEFAULT_STYLE_CLASS); this.ripplerContainer = new RipplerContainer(container); FXUtils.onClicked(this, this::fire); this.getChildren().setAll(ripplerContainer); } public void fire() { fireEvent(new ActionEvent()); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineComponent.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectPropertyBase; import javafx.beans.property.StringProperty; import javafx.beans.property.StringPropertyBase; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.*; import org.jackhuang.hmcl.ui.SVG; import java.util.Arrays; import java.util.Objects; /// @author Glavo public abstract class LineComponent extends StackPane implements NoPaddingComponent { private static final String DEFAULT_STYLE_CLASS = "line-component"; private static final double MIN_HEIGHT = 48.0; private static final PseudoClass PSEUDO_LARGER_TITLE = PseudoClass.getPseudoClass("large-title"); protected static final int IDX_LEADING = 0; protected static final int IDX_TITLE = 1; protected static final int IDX_TRAILING = 2; public static final double SPACING = 12; public static final double DEFAULT_ICON_SIZE = 20; public static void setMargin(Node child, Insets value) { HBox.setMargin(child, value); } protected final HBox container; private final Label titleLabel; private final VBox titleContainer; public LineComponent() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); this.setMinHeight(MIN_HEIGHT); this.container = new HBox(SPACING); container.getStyleClass().add("line-component-container"); container.setAlignment(Pos.CENTER_LEFT); this.titleLabel = new Label(); titleLabel.getStyleClass().add("title-label"); titleLabel.setMinWidth(Region.USE_PREF_SIZE); this.titleContainer = new VBox(titleLabel); titleContainer.getStyleClass().add("title-container"); titleContainer.setMouseTransparent(true); titleContainer.setAlignment(Pos.CENTER_LEFT); titleContainer.minWidthProperty().bind(titleLabel.prefWidthProperty()); HBox.setHgrow(titleContainer, Priority.ALWAYS); this.setNode(IDX_TITLE, titleContainer); this.getChildren().setAll(container); } private Node[] nodes = new Node[2]; protected void setNode(int idx, Node node) { if (nodes.length <= idx) nodes = Arrays.copyOf(nodes, idx + 1); if (nodes[idx] != node) { nodes[idx] = node; container.getChildren().setAll(Arrays.stream(nodes).filter(Objects::nonNull).toArray(Node[]::new)); } } public void setLargeTitle(boolean largeTitle) { pseudoClassStateChanged(PSEUDO_LARGER_TITLE, largeTitle); } private final StringProperty title = new StringPropertyBase() { @Override public Object getBean() { return LineComponent.this; } @Override public String getName() { return "title"; } @Override protected void invalidated() { titleLabel.setText(get()); } }; public final StringProperty titleProperty() { return title; } public String getTitle() { return titleProperty().get(); } public void setTitle(String title) { titleProperty().set(title); } private StringProperty subtitle; public final StringProperty subtitleProperty() { if (subtitle == null) { subtitle = new StringPropertyBase() { private Label subtitleLabel; @Override public String getName() { return "subtitle"; } @Override public Object getBean() { return LineComponent.this; } @Override protected void invalidated() { String subtitle = get(); if (subtitle != null && !subtitle.isEmpty()) { if (subtitleLabel == null) { subtitleLabel = new Label(); subtitleLabel.setWrapText(true); subtitleLabel.setMinHeight(Region.USE_PREF_SIZE); subtitleLabel.getStyleClass().add("subtitle-label"); } subtitleLabel.setText(subtitle); if (titleContainer.getChildren().size() == 1) titleContainer.getChildren().add(subtitleLabel); } else if (subtitleLabel != null) { subtitleLabel.setText(null); if (titleContainer.getChildren().size() == 2) titleContainer.getChildren().remove(1); } } }; } return subtitle; } public final String getSubtitle() { return subtitle != null ? subtitle.get() : null; } public final void setSubtitle(String subtitle) { subtitleProperty().set(subtitle); } private ObjectProperty leading; public final ObjectProperty leadingProperty() { if (leading == null) { leading = new ObjectPropertyBase<>() { @Override public Object getBean() { return LineComponent.this; } @Override public String getName() { return "leading"; } @Override protected void invalidated() { setNode(IDX_LEADING, get()); } }; } return leading; } public final Node getLeading() { return leadingProperty().get(); } public final void setLeading(Node node) { leadingProperty().set(node); } public void setLeading(Image icon) { setLeading(icon, -1); } public void setLeading(Image icon, double size) { var imageView = new ImageView(icon); if (size > 0) { imageView.setFitWidth(size); imageView.setFitHeight(size); imageView.setPreserveRatio(true); imageView.setSmooth(true); } imageView.setMouseTransparent(true); setNode(IDX_LEADING, imageView); } public void setLeading(SVG svg) { setLeading(svg, DEFAULT_ICON_SIZE); } public void setLeading(SVG svg, double size) { Node node = svg.createIcon(size); node.setMouseTransparent(true); setNode(IDX_LEADING, node); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineFileChooserButton.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.*; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; import javafx.stage.Stage; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.util.io.FileUtils; import java.nio.file.Files; import java.nio.file.Path; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class LineFileChooserButton extends LineButton { private static final String DEFAULT_STYLE_CLASS = "line-file-select-button"; public LineFileChooserButton() { getStyleClass().add(DEFAULT_STYLE_CLASS); setTrailingIcon(SVG.EDIT); } /// Converts the given path to absolute/relative(if possible) path according to [#convertToRelativePathProperty()]. private String processPath(Path path) { if (isConvertToRelativePath() && path.isAbsolute()) { try { return Metadata.CURRENT_DIRECTORY.relativize(path).normalize().toString(); } catch (IllegalArgumentException e) { // the given path can't be relativized against current path } } return path.normalize().toString(); } @Override public void fire() { super.fire(); Stage owner = Controllers.getStage(); // TODO: Allow user to set owner stage String windowTitle = getFileChooserTitle(); Path initialDirectory = null; if (getLocation() != null) { Path file; try { file = FileUtils.toAbsolute(Path.of(getLocation())); if (Files.exists(file)) { if (Files.isRegularFile(file)) initialDirectory = file.getParent(); else if (Files.isDirectory(file)) initialDirectory = file; } } catch (IllegalArgumentException e) { LOG.warning("Failed to resolve path: " + getLocation()); } } Path path; Type type = getType(); if (type == Type.OPEN_DIRECTORY) { var directoryChooser = new DirectoryChooser(); if (windowTitle != null) directoryChooser.setTitle(windowTitle); if (initialDirectory != null) directoryChooser.setInitialDirectory(initialDirectory.toFile()); path = FileUtils.toPath(directoryChooser.showDialog(owner)); } else { var fileChooser = new FileChooser(); if (windowTitle != null) fileChooser.setTitle(windowTitle); if (initialDirectory != null) fileChooser.setInitialDirectory(initialDirectory.toFile()); if (extensionFilters != null) fileChooser.getExtensionFilters().setAll(extensionFilters); fileChooser.setInitialFileName(getInitialFileName()); path = FileUtils.toPath(switch (type) { case OPEN_FILE -> fileChooser.showOpenDialog(owner); case SAVE_FILE -> fileChooser.showSaveDialog(owner); default -> throw new AssertionError("Unknown Type: " + type); }); } if (path != null) { setLocation(processPath(path)); } } private final StringProperty location = new StringPropertyBase() { @Override public Object getBean() { return LineFileChooserButton.this; } @Override public String getName() { return "location"; } @Override protected void invalidated() { setTrailingText(get()); } }; public StringProperty locationProperty() { return location; } public String getLocation() { return locationProperty().get(); } public void setLocation(String location) { locationProperty().set(location); } private final StringProperty fileChooserTitle = new SimpleStringProperty(this, "fileChooserTitle"); public StringProperty fileChooserTitleProperty() { return fileChooserTitle; } public String getFileChooserTitle() { return fileChooserTitleProperty().get(); } public void setFileChooserTitle(String fileChooserTitle) { fileChooserTitleProperty().set(fileChooserTitle); } private ObjectProperty type; public ObjectProperty typeProperty() { if (type == null) { type = new SimpleObjectProperty<>(this, "type", Type.OPEN_FILE); } return type; } public Type getType() { return type != null ? type.get() : Type.OPEN_FILE; } public void setType(Type type) { typeProperty().set(type); } private ObjectProperty initialFileName; public final ObjectProperty initialFileNameProperty() { if (initialFileName == null) initialFileName = new SimpleObjectProperty<>(this, "initialFileName"); return initialFileName; } public final String getInitialFileName() { return initialFileName != null ? initialFileName.get() : null; } public final void setInitialFileName(String value) { initialFileNameProperty().set(value); } private ObservableList extensionFilters; public ObservableList getExtensionFilters() { if (extensionFilters == null) extensionFilters = FXCollections.observableArrayList(); return extensionFilters; } private BooleanProperty convertToRelativePath; public BooleanProperty convertToRelativePathProperty() { if (convertToRelativePath == null) convertToRelativePath = new BooleanPropertyBase(false) { @Override public Object getBean() { return LineFileChooserButton.this; } @Override public String getName() { return "convertToRelativePath"; } @Override protected void invalidated() { String location = getLocation(); if (location == null) return; try { setLocation(processPath(FileUtils.toAbsolute(Path.of(getLocation())))); } catch (IllegalArgumentException ignored) { } } }; return convertToRelativePath; } public boolean isConvertToRelativePath() { return convertToRelativePath != null && convertToRelativePath.get(); } public void setConvertToRelativePath(boolean convertToRelativePath) { convertToRelativePathProperty().set(convertToRelativePath); } public enum Type { OPEN_FILE, OPEN_DIRECTORY, SAVE_FILE } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LinePane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectPropertyBase; import javafx.scene.Node; /// @author Glavo public class LinePane extends LineComponent { private static final String DEFAULT_STYLE_CLASS = "line-pane"; public LinePane() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); } private ObjectProperty right; public ObjectProperty rightProperty() { if (right == null) { right = new ObjectPropertyBase<>() { @Override public Object getBean() { return LinePane.this; } @Override public String getName() { return "right"; } @Override protected void invalidated() { setNode(IDX_TRAILING, get()); } }; } return right; } public Node getRight() { return rightProperty().get(); } public void setRight(Node right) { rightProperty().set(right); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineSelectButton.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXPopup; import javafx.beans.InvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.property.ListProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleListProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.input.*; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.util.javafx.MappedObservableList; import java.util.Collection; import java.util.Objects; import java.util.function.Function; import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; /// @author Glavo public final class LineSelectButton extends LineButton { private static final String DEFAULT_STYLE_CLASS = "line-select-button"; private static final PseudoClass SELECTED_PSEUDO_CLASS = PseudoClass.getPseudoClass("selected"); private JFXPopup popup; public LineSelectButton() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); InvalidationListener updateTrailingText = observable -> { T value = getValue(); if (value != null) { Function converter = getConverter(); setTrailingText(converter != null ? converter.apply(value) : value.toString()); } else { setTrailingText(null); } }; converterProperty().addListener(updateTrailingText); valueProperty().addListener(updateTrailingText); setTrailingIcon(SVG.UNFOLD_MORE); ripplerContainer.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> { if (event.getButton() == MouseButton.SECONDARY) { if (popup != null) popup.hide(); event.consume(); } }); } @Override public void fire() { super.fire(); if (popup == null) { PopupMenu popupMenu = new PopupMenu(); this.popup = new JFXPopup(popupMenu); popupMenu.addEventHandler(KeyEvent.KEY_PRESSED, this::handleKeyEvent); ripplerContainer.addEventFilter(ScrollEvent.ANY, ignored -> popup.hide()); Bindings.bindContent(popupMenu.getContent(), MappedObservableList.create(itemsProperty(), item -> { VBox vbox = new VBox(); var itemTitleLabel = new Label(); itemTitleLabel.getStyleClass().add("title-label"); itemTitleLabel.textProperty().bind(Bindings.createStringBinding(() -> { if (item == null) return ""; Function converter = getConverter(); return converter != null ? converter.apply(item) : Objects.toString(item, ""); }, converterProperty())); var itemSubtitleLabel = new Label(); itemSubtitleLabel.getStyleClass().add("subtitle-label"); itemSubtitleLabel.textProperty().bind(Bindings.createStringBinding(() -> { Function descriptionConverter = getDescriptionConverter(); return descriptionConverter != null ? descriptionConverter.apply(item) : ""; }, descriptionConverterProperty())); FXUtils.onChangeAndOperate(itemSubtitleLabel.textProperty(), text -> { if (text == null || text.isEmpty()) { vbox.getChildren().setAll(itemTitleLabel); } else { vbox.getChildren().setAll(itemTitleLabel, itemSubtitleLabel); } }); var wrapper = new StackPane(vbox); wrapper.setAlignment(Pos.CENTER_LEFT); wrapper.getStyleClass().add("menu-container"); wrapper.setMouseTransparent(true); RipplerContainer ripplerContainer = new RipplerContainer(wrapper); FXUtils.onClicked(ripplerContainer, () -> { setValue(item); popup.hide(); }); FXUtils.onChangeAndOperate(valueProperty(), value -> wrapper.pseudoClassStateChanged(SELECTED_PSEUDO_CLASS, Objects.equals(value, item))); return ripplerContainer; })); popup.showingProperty().addListener((observable, oldValue, newValue) -> ripplerContainer.getRippler().setRipplerDisabled(newValue)); } if (popup.isShowing()) { popup.hide(); } else { JFXPopup.PopupVPosition vPosition = determineOptimalPopupPosition(this, popup); popup.show(this, vPosition, JFXPopup.PopupHPosition.RIGHT, 0, vPosition == JFXPopup.PopupVPosition.TOP ? this.getHeight() : -this.getHeight(), true); } } private final ObjectProperty value = new SimpleObjectProperty<>(this, "value"); public ObjectProperty valueProperty() { return value; } public T getValue() { return valueProperty().get(); } public void setValue(T value) { valueProperty().set(value); } private final ObjectProperty> converter = new SimpleObjectProperty<>(this, "converter"); public ObjectProperty> converterProperty() { return converter; } public Function getConverter() { return converterProperty().get(); } public void setConverter(Function value) { converterProperty().set(value); } private ObjectProperty> descriptionConverter; public ObjectProperty> descriptionConverterProperty() { if (descriptionConverter == null) descriptionConverter = new SimpleObjectProperty<>(this, "descriptionConverter"); return descriptionConverter; } public Function getDescriptionConverter() { return descriptionConverterProperty().get(); } public void setDescriptionConverter(Function value) { descriptionConverterProperty().set(value); } private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.emptyObservableList()); public ListProperty itemsProperty() { return items; } public void setItems(ObservableList value) { itemsProperty().set(value); } public void setItems(Collection value) { if (value instanceof ObservableList observableList) { this.setItems(observableList); } else { this.setItems(FXCollections.observableArrayList(value)); } } @SafeVarargs public final void setItems(T... values) { this.setItems(FXCollections.observableArrayList(values)); } public ObservableList getItems() { return items.get(); } private void handleKeyEvent(KeyEvent event) { ObservableList list = getItems(); if (list == null || list.isEmpty()) return; int index = list.indexOf(getValue()); var code = event.getCode(); if (code == KeyCode.UP) { if (index > 0) { setValue(list.get(index - 1)); } else { setValue(list.get(list.size() - 1)); } event.consume(); } else if (code == KeyCode.DOWN) { if (index < list.size() - 1) { setValue(list.get(index + 1)); } else { setValue(list.get(0)); } event.consume(); } else if (code == KeyCode.ENTER || code == KeyCode.ESCAPE) { if (popup != null && popup.isShowing()) { popup.hide(); event.consume(); } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineTextPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.property.StringProperty; import javafx.beans.property.StringPropertyBase; import javafx.scene.control.Label; import org.jackhuang.hmcl.ui.FXUtils; /// @author Glavo public final class LineTextPane extends LineComponent { private static final String DEFAULT_STYLE_CLASS = "line-text-pane"; public LineTextPane() { this.getStyleClass().addAll(DEFAULT_STYLE_CLASS); } private StringProperty text; public StringProperty textProperty() { if (text == null) { text = new StringPropertyBase() { private Label rightLabel; @Override public Object getBean() { return LineTextPane.this; } @Override public String getName() { return "text"; } @Override protected void invalidated() { String text = get(); if (text != null && !text.isEmpty()) { if (rightLabel == null) { rightLabel = FXUtils.newSafeTruncatedLabel(); FXUtils.copyOnDoubleClick(rightLabel); } rightLabel.setText(text); setNode(IDX_TRAILING, rightLabel); } else { if (rightLabel != null) rightLabel.setText(null); setNode(IDX_TRAILING, null); } } }; } return text; } public String getText() { return textProperty().get(); } public void setText(String text) { textProperty().set(text); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/LineToggleButton.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXToggleButton; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.ui.FXUtils; public final class LineToggleButton extends LineButtonBase { private static final String DEFAULT_STYLE_CLASS = "line-toggle-button"; private final JFXToggleButton toggleButton; public LineToggleButton() { this.getStyleClass().add(DEFAULT_STYLE_CLASS); this.toggleButton = new JFXToggleButton(); toggleButton.selectedProperty().bindBidirectional(selectedProperty()); toggleButton.setSize(8); FXUtils.setLimitHeight(toggleButton, 30); setNode(IDX_TRAILING, toggleButton); } @Override public void fire() { toggleButton.fire(); super.fire(); } private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected"); public BooleanProperty selectedProperty() { return selected; } public boolean isSelected() { return selectedProperty().get(); } public void setSelected(boolean selected) { selectedProperty().set(selected); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXListView; import javafx.beans.binding.DoubleBinding; import javafx.css.PseudoClass; import javafx.scene.control.ListCell; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.ui.FXUtils; public abstract class MDListCell extends ListCell { private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); private final StackPane container = new StackPane(); private final StackPane root = new StackPane(); public MDListCell(JFXListView listView) { setText(null); setGraphic(null); root.getStyleClass().add("md-list-cell"); RipplerContainer ripplerContainer = new RipplerContainer(container); root.getChildren().setAll(ripplerContainer); Region clippedContainer = (Region) listView.lookup(".clipped-container"); setPrefWidth(0); if (clippedContainer != null) { DoubleBinding converted = clippedContainer.widthProperty().subtract(1); maxWidthProperty().bind(converted); prefWidthProperty().bind(converted); minWidthProperty().bind(converted); } } @Override protected void updateItem(T item, boolean empty) { super.updateItem(item, empty); updateControl(item, empty); if (empty) { setGraphic(null); } else { setGraphic(root); } } protected StackPane getContainer() { return container; } protected void setSelectable() { FXUtils.onChangeAndOperate(selectedProperty(), selected -> { root.pseudoClassStateChanged(SELECTED, selected); }); } protected abstract void updateControl(T item, boolean empty); } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MenuSeparator.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.geometry.Insets; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; public class MenuSeparator extends StackPane { public MenuSeparator() { Rectangle rect = new Rectangle(); rect.widthProperty().bind(widthProperty().add(-14)); rect.setHeight(1); rect.setFill(Color.GRAY); maxHeightProperty().set(10); setPadding(new Insets(3)); getChildren().setAll(rect); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MenuUpDownButton.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXButton; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.Label; import javafx.scene.control.Skin; import javafx.scene.control.SkinBase; import javafx.scene.layout.HBox; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; public class MenuUpDownButton extends Control { private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected"); private final StringProperty text = new SimpleStringProperty(this, "text"); public MenuUpDownButton() { this.getStyleClass().add("menu-up-down-button"); } @Override protected Skin createDefaultSkin() { return new MenuUpDownButtonSkin(this); } public boolean isSelected() { return selected.get(); } public BooleanProperty selectedProperty() { return selected; } public void setSelected(boolean selected) { this.selected.set(selected); } public String getText() { return text.get(); } public StringProperty textProperty() { return text; } public void setText(String text) { this.text.set(text); } private static class MenuUpDownButtonSkin extends SkinBase { protected MenuUpDownButtonSkin(MenuUpDownButton control) { super(control); HBox content = new HBox(8); content.setAlignment(Pos.CENTER); Label label = new Label(); label.textProperty().bind(control.text); Node up = SVG.ARROW_DROP_UP.createIcon(16); Node down = SVG.ARROW_DROP_DOWN.createIcon(16); JFXButton button = new JFXButton(); button.setGraphic(content); button.setOnAction(e -> { control.selected.set(!control.isSelected()); }); FXUtils.onChangeAndOperate(control.selected, selected -> { if (selected) { content.getChildren().setAll(label, up); } else { content.getChildren().setAll(label, down); } }); getChildren().setAll(button); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MessageDialogPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXButton; import javafx.event.ActionEvent; import javafx.scene.Node; import javafx.scene.control.ButtonBase; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.text.TextFlow; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.Locale; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class MessageDialogPane extends HBox { public enum MessageType { ERROR(SVG.ERROR), INFO(SVG.INFO), WARNING(SVG.WARNING), QUESTION(SVG.HELP), SUCCESS(SVG.CHECK_CIRCLE); private final SVG icon; MessageType(SVG icon) { this.icon = icon; } public SVG getIcon() { return icon; } public String getDisplayName() { return i18n("message." + name().toLowerCase(Locale.ROOT)); } } private final HBox actions; private @Nullable ButtonBase cancelButton; public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull MessageType type) { this.setSpacing(16); this.getStyleClass().add("jfx-dialog-layout"); Label graphic = new Label(); graphic.setTranslateX(10); graphic.setTranslateY(10); graphic.setMinSize(40, 40); graphic.setMaxSize(40, 40); graphic.setGraphic(type.getIcon().createIcon(40)); VBox vbox = new VBox(); HBox.setHgrow(vbox, Priority.ALWAYS); { StackPane titlePane = new StackPane(); titlePane.getStyleClass().addAll("jfx-layout-heading", "title"); titlePane.getChildren().setAll(new Label(title != null ? title : type.getDisplayName())); StackPane content = new StackPane(); content.getStyleClass().add("jfx-layout-body"); EnhancedTextFlow textFlow = new EnhancedTextFlow(text); textFlow.setStyle("-fx-font-size: 14px;"); if (textFlow.computePrefHeight(400.0) <= 350.0) content.getChildren().setAll(textFlow); else { ScrollPane scrollPane = new ScrollPane(textFlow); FXUtils.smoothScrolling(scrollPane); scrollPane.setPrefHeight(350); VBox.setVgrow(scrollPane, Priority.ALWAYS); scrollPane.setFitToWidth(true); content.getChildren().setAll(scrollPane); } actions = new HBox(); actions.getStyleClass().add("jfx-layout-actions"); vbox.getChildren().setAll(titlePane, content, actions); } this.getChildren().setAll(graphic, vbox); onEscPressed(this, () -> { if (cancelButton != null) { cancelButton.fire(); } }); } public void addButton(Node btn) { btn.addEventHandler(ActionEvent.ACTION, e -> fireEvent(new DialogCloseEvent())); actions.getChildren().add(btn); } public void setCancelButton(@Nullable ButtonBase btn) { cancelButton = btn; } public ButtonBase getCancelButton() { return cancelButton; } private static final class EnhancedTextFlow extends TextFlow { EnhancedTextFlow(String text) { this.getChildren().setAll(FXUtils.parseSegment(text, Controllers::onHyperlinkAction)); } @Override public double computePrefHeight(double width) { return super.computePrefHeight(width); } } public static class Builder { private final MessageDialogPane dialog; public Builder(String text, String title, MessageType type) { this.dialog = new MessageDialogPane(text, title, type); } public Builder addHyperLink(String text, String externalLink) { JFXHyperlink link = new JFXHyperlink(text); link.setExternalLink(externalLink); dialog.actions.getChildren().add(link); return this; } public Builder addAction(Node actionNode) { dialog.addButton(actionNode); actionNode.getStyleClass().add("dialog-accept"); return this; } public Builder addAction(String text, @Nullable Runnable action) { JFXButton btnAction = new JFXButton(text); btnAction.getStyleClass().add("dialog-accept"); if (action != null) { btnAction.setOnAction(e -> action.run()); } dialog.addButton(btnAction); return this; } public Builder ok(@Nullable Runnable ok) { JFXButton btnOk = new JFXButton(i18n("button.ok")); btnOk.getStyleClass().add("dialog-accept"); if (ok != null) { btnOk.setOnAction(e -> ok.run()); } dialog.addButton(btnOk); dialog.setCancelButton(btnOk); return this; } public Builder addCancel(@Nullable Runnable cancel) { return addCancel(i18n("button.cancel"), cancel); } public Builder addCancel(String cancelText, @Nullable Runnable cancel) { JFXButton btnCancel = new JFXButton(cancelText); btnCancel.setButtonType(JFXButton.ButtonType.FLAT); btnCancel.getStyleClass().add("dialog-cancel"); if (cancel != null) { btnCancel.setOnAction(e -> cancel.run()); } dialog.addButton(btnCancel); dialog.setCancelButton(btnCancel); return this; } public Builder yesOrNo(@Nullable Runnable yes, @Nullable Runnable no) { JFXButton btnYes = new JFXButton(i18n("button.yes")); btnYes.getStyleClass().add("dialog-accept"); if (yes != null) { btnYes.setOnAction(e -> yes.run()); } dialog.addButton(btnYes); addCancel(i18n("button.no"), no); return this; } public Builder actionOrCancel(ButtonBase actionButton, Runnable cancel) { dialog.addButton(actionButton); addCancel(cancel); return this; } public MessageDialogPane build() { return dialog; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MultiFileItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXColorPicker; import com.jfoenix.controls.JFXRadioButton; import com.jfoenix.controls.JFXTextField; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.property.*; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.ColorPicker; import javafx.scene.control.Label; import javafx.scene.control.Toggle; import javafx.scene.control.ToggleGroup; import javafx.scene.layout.BorderPane; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.stage.FileChooser; import org.jackhuang.hmcl.theme.ThemeColor; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.StringUtils; import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Collectors; public final class MultiFileItem extends VBox { private final ObjectProperty selectedData = new SimpleObjectProperty<>(this, "selectedData"); private final ObjectProperty fallbackData = new SimpleObjectProperty<>(this, "fallbackData"); private final ToggleGroup group = new ToggleGroup(); private Consumer toggleSelectedListener; @SuppressWarnings("unchecked") public MultiFileItem() { setPadding(new Insets(0, 0, 10, 0)); setSpacing(8); group.selectedToggleProperty().addListener((a, b, newValue) -> { selectedData.set(newValue != null ? (T) newValue.getUserData() : null); if (toggleSelectedListener != null) toggleSelectedListener.accept(newValue); }); selectedData.addListener((a, b, newValue) -> { Optional selecting = group.getToggles().stream() .filter(it -> Objects.equals(it.getUserData(), newValue)) .findFirst(); if (!selecting.isPresent()) { selecting = group.getToggles().stream() .filter(it -> it.getUserData() == getFallbackData()) .findFirst(); } selecting.ifPresent(toggle -> toggle.setSelected(true)); }); } public void loadChildren(Collection> options) { getChildren().setAll(options.stream() .map(option -> option.createItem(group)) .collect(Collectors.toList())); } public ToggleGroup getGroup() { return group; } public void setToggleSelectedListener(Consumer consumer) { toggleSelectedListener = consumer; } public T getSelectedData() { return selectedData.get(); } public ObjectProperty selectedDataProperty() { return selectedData; } public void setSelectedData(T selectedData) { this.selectedData.set(selectedData); } public T getFallbackData() { return fallbackData.get(); } public ObjectProperty fallbackDataProperty() { return fallbackData; } public void setFallbackData(T fallbackData) { this.fallbackData.set(fallbackData); } public static class Option { protected final String title; protected String subtitle; protected String tooltip; protected final T data; protected final BooleanProperty selected = new SimpleBooleanProperty(); protected final JFXRadioButton left = new JFXRadioButton(); public Option(String title, T data) { this.title = title; this.data = data; } public T getData() { return data; } public String getTitle() { return title; } public String getSubtitle() { return subtitle; } public Option setSubtitle(String subtitle) { this.subtitle = subtitle; return this; } public Option setTooltip(String tooltip) { this.tooltip = tooltip; return this; } public boolean isSelected() { return left.isSelected(); } public BooleanProperty selectedProperty() { return left.selectedProperty(); } public void setSelected(boolean selected) { left.setSelected(selected); } protected Node createItem(ToggleGroup group) { BorderPane pane = new BorderPane(); pane.setPadding(new Insets(3)); FXUtils.setLimitHeight(pane, 30); left.setText(title); BorderPane.setAlignment(left, Pos.CENTER_LEFT); left.setToggleGroup(group); left.setUserData(data); if (StringUtils.isNotBlank(tooltip)) FXUtils.installFastTooltip(left, tooltip); pane.setLeft(left); if (StringUtils.isNotBlank(subtitle)) { Label center = new Label(subtitle); BorderPane.setAlignment(center, Pos.CENTER_RIGHT); center.setWrapText(true); center.getStyleClass().add("subtitle-label"); center.setStyle("-fx-font-size: 10;"); center.setPadding(new Insets(0, 0, 0, 15)); pane.setCenter(center); } return pane; } } public static final class StringOption extends Option { private final JFXTextField customField = new JFXTextField(); public StringOption(String title, T data) { super(title, data); } public JFXTextField getCustomField() { return customField; } public String getValue() { return customField.getText(); } public StringProperty valueProperty() { return customField.textProperty(); } public void setValue(String value) { customField.setText(value); } public StringOption bindBidirectional(Property property) { FXUtils.bindString(customField, property); return this; } public StringOption setValidators(ValidatorBase... validators) { customField.setValidators(validators); return this; } @Override protected Node createItem(ToggleGroup group) { BorderPane pane = new BorderPane(); pane.setPadding(new Insets(3)); FXUtils.setLimitHeight(pane, 30); left.setText(title); BorderPane.setAlignment(left, Pos.CENTER_LEFT); left.setToggleGroup(group); left.setUserData(data); pane.setLeft(left); BorderPane.setAlignment(customField, Pos.CENTER_RIGHT); customField.disableProperty().bind(left.selectedProperty().not()); if (!customField.getValidators().isEmpty()) { FXUtils.setValidateWhileTextChanged(customField, true); } pane.setRight(customField); return pane; } } public static final class FileOption extends Option { private final FileSelector selector = new FileSelector(); public FileOption(String title, T data) { super(title, data); } public String getValue() { return selector.getValue(); } public StringProperty valueProperty() { return selector.valueProperty(); } public void setValue(String value) { selector.setValue(value); } public FileOption setDirectory(boolean directory) { selector.setDirectory(directory); return this; } public FileOption bindBidirectional(Property property) { selector.valueProperty().bindBidirectional(property); return this; } public FileOption setChooserTitle(String chooserTitle) { selector.setChooserTitle(chooserTitle); return this; } public FileOption addExtensionFilter(FileChooser.ExtensionFilter filter) { selector.getExtensionFilters().add(filter); return this; } @Override protected Node createItem(ToggleGroup group) { BorderPane pane = new BorderPane(); pane.setPadding(new Insets(3)); FXUtils.setLimitHeight(pane, 30); left.setText(title); BorderPane.setAlignment(left, Pos.CENTER_LEFT); left.setToggleGroup(group); left.setUserData(data); pane.setLeft(left); selector.disableProperty().bind(left.selectedProperty().not()); BorderPane.setAlignment(selector, Pos.CENTER_RIGHT); pane.setRight(selector); return pane; } } public static final class PaintOption extends Option { private final ColorPicker colorPicker = new JFXColorPicker(); public PaintOption(String title, T data) { super(title, data); } public PaintOption setCustomColors(List colors) { colorPicker.getCustomColors().setAll(colors); return this; } public PaintOption bindBidirectional(Property property) { FXUtils.bindPaint(colorPicker, property); return this; } public PaintOption bindThemeColorBidirectional(Property property) { ThemeColor.bindBidirectional(colorPicker, property); return this; } @Override protected Node createItem(ToggleGroup group) { BorderPane pane = new BorderPane(); pane.setPadding(new Insets(3)); FXUtils.setLimitHeight(pane, 30); left.setText(title); BorderPane.setAlignment(left, Pos.CENTER_LEFT); left.setToggleGroup(group); left.setUserData(data); pane.setLeft(left); colorPicker.disableProperty().bind(left.selectedProperty().not()); BorderPane.setAlignment(colorPicker, Pos.CENTER_RIGHT); pane.setRight(colorPicker); return pane; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/Navigator.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.animation.Interpolator; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.event.Event; import javafx.event.EventHandler; import javafx.event.EventType; import javafx.scene.Node; import javafx.scene.layout.Region; import javafx.util.Duration; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.Motion; import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.wizard.Navigation; import java.util.Objects; import java.util.Optional; import java.util.Stack; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class Navigator extends TransitionPane { private static final String PROPERTY_DIALOG_CLOSE_HANDLER = Navigator.class.getName() + ".closeListener"; private final BooleanProperty backable = new SimpleBooleanProperty(this, "backable"); private final Stack stack = new Stack<>(); private boolean initialized = false; public void init(Node init) { stack.push(init); backable.set(canGoBack()); setContent(init, ContainerAnimations.NONE); fireEvent(new NavigationEvent(this, init, Navigation.NavigationDirection.START, NavigationEvent.NAVIGATED)); if (init instanceof PageAware) ((PageAware) init).onPageShown(); initialized = true; } public void navigate(Node node, AnimationProducer animationProducer) { navigate(node, animationProducer, Motion.SHORT4, Motion.EASE); } public void navigate(Node node, AnimationProducer animationProducer, Duration duration, Interpolator interpolator) { FXUtils.checkFxUserThread(); if (!initialized) throw new IllegalStateException("Navigator must have a root page"); Node from = stack.peek(); if (from == node) return; LOG.info("Navigate to " + node); stack.push(node); backable.set(canGoBack()); NavigationEvent navigating = new NavigationEvent(this, from, Navigation.NavigationDirection.NEXT, NavigationEvent.NAVIGATING); fireEvent(navigating); node.fireEvent(navigating); node.getProperties().put("hmcl.navigator.animation", animationProducer); setContent(node, animationProducer, duration, interpolator); NavigationEvent navigated = new NavigationEvent(this, node, Navigation.NavigationDirection.NEXT, NavigationEvent.NAVIGATED); node.fireEvent(navigated); if (node instanceof PageAware) ((PageAware) node).onPageShown(); EventHandler handler = event -> close(node); node.getProperties().put(PROPERTY_DIALOG_CLOSE_HANDLER, handler); node.addEventHandler(PageCloseEvent.CLOSE, handler); } public void close() { close(stack.peek()); } public void clear() { while (stack.size() > 1) close(stack.peek()); } @SuppressWarnings("unchecked") public void close(Node from) { FXUtils.checkFxUserThread(); if (!initialized) throw new IllegalStateException("Navigator must have a root page"); if (stack.peek() != from) { // Allow page to be closed multiple times. LOG.info("Closing already closed page: " + from, new Throwable()); return; } LOG.info("Closed page " + from); Node poppedNode = stack.pop(); NavigationEvent exited = new NavigationEvent(this, poppedNode, Navigation.NavigationDirection.PREVIOUS, NavigationEvent.EXITED); poppedNode.fireEvent(exited); if (poppedNode instanceof PageAware pageAware) pageAware.onPageHidden(); backable.set(canGoBack()); Node node = stack.peek(); NavigationEvent navigating = new NavigationEvent(this, from, Navigation.NavigationDirection.PREVIOUS, NavigationEvent.NAVIGATING); fireEvent(navigating); node.fireEvent(navigating); Object obj = from.getProperties().get("hmcl.navigator.animation"); if (obj instanceof AnimationProducer animationProducer) { setContent(node, Objects.requireNonNullElse(animationProducer.opposite(), animationProducer)); } else { setContent(node, ContainerAnimations.NONE); } NavigationEvent navigated = new NavigationEvent(this, node, Navigation.NavigationDirection.PREVIOUS, NavigationEvent.NAVIGATED); node.fireEvent(navigated); Optional.ofNullable(from.getProperties().get(PROPERTY_DIALOG_CLOSE_HANDLER)) .ifPresent(handler -> from.removeEventHandler(PageCloseEvent.CLOSE, (EventHandler) handler)); } public Node getCurrentPage() { return stack.peek(); } public boolean canGoBack() { return stack.size() > 1; } public boolean isBackable() { return backable.get(); } public BooleanProperty backableProperty() { return backable; } public void setBackable(boolean backable) { this.backable.set(backable); } public int size() { return stack.size(); } @Override public void setContent(Node newView, AnimationProducer transition, Duration duration, Interpolator interpolator) { super.setContent(newView, transition, duration, interpolator); if (newView instanceof Region region) { region.setMinSize(0, 0); FXUtils.setOverflowHidden(region); } } public EventHandler getOnNavigated() { return onNavigated.get(); } public ObjectProperty> onNavigatedProperty() { return onNavigated; } public void setOnNavigated(EventHandler onNavigated) { this.onNavigated.set(onNavigated); } private final ObjectProperty> onNavigated = new SimpleObjectProperty>(this, "onNavigated") { @Override protected void invalidated() { setEventHandler(NavigationEvent.NAVIGATED, get()); } }; public EventHandler getOnNavigating() { return onNavigating.get(); } public ObjectProperty> onNavigatingProperty() { return onNavigating; } public void setOnNavigating(EventHandler onNavigating) { this.onNavigating.set(onNavigating); } private final ObjectProperty> onNavigating = new SimpleObjectProperty>(this, "onNavigating") { @Override protected void invalidated() { setEventHandler(NavigationEvent.NAVIGATING, get()); } }; public static final class NavigationEvent extends Event { public static final EventType EXITED = new EventType<>("EXITED"); public static final EventType NAVIGATED = new EventType<>("NAVIGATED"); public static final EventType NAVIGATING = new EventType<>("NAVIGATING"); private final Navigator source; private final Node node; private final Navigation.NavigationDirection direction; public NavigationEvent(Navigator source, Node target, Navigation.NavigationDirection direction, EventType eventType) { super(source, target, eventType); this.source = source; this.node = target; this.direction = direction; } @Override public Navigator getSource() { return source; } public Node getNode() { return node; } public Navigation.NavigationDirection getDirection() { return direction; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NoPaddingComponent.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; /// Marker interface for no padding in [ComponentList]. /// /// @author Glavo interface NoPaddingComponent { } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NoneMultipleSelectionModel.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.control.MultipleSelectionModel; public class NoneMultipleSelectionModel extends MultipleSelectionModel { public NoneMultipleSelectionModel() { } @Override public ObservableList getSelectedIndices() { return FXCollections.emptyObservableList(); } @Override public ObservableList getSelectedItems() { return FXCollections.emptyObservableList(); } @Override public void selectIndices(int index, int... indices) { } @Override public void selectAll() { } @Override public void clearAndSelect(int index) { } @Override public void select(int index) { } @Override public void select(T obj) { } @Override public void clearSelection(int index) { } @Override public void clearSelection() { } @Override public boolean isSelected(int index) { return false; } @Override public boolean isEmpty() { return true; } @Override public void selectPrevious() { } @Override public void selectNext() { } @Override public void selectFirst() { } @Override public void selectLast() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/NumberValidator.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.NamedArg; import javafx.scene.control.TextInputControl; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; public class NumberValidator extends ValidatorBase { private final boolean nullable; public NumberValidator() { this(false); } public NumberValidator(@NamedArg("nullable") boolean nullable) { this.nullable = nullable; } public NumberValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) { super(message); this.nullable = nullable; } @Override protected void eval() { if (srcControl.get() instanceof TextInputControl) { evalTextInputField(); } } private void evalTextInputField() { TextInputControl textField = ((TextInputControl) srcControl.get()); if (StringUtils.isBlank(textField.getText())) hasErrors.set(!nullable); else hasErrors.set(Lang.toIntOrNull(textField.getText()) == null); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionsList.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.CssMetaData; import javafx.css.Styleable; import javafx.css.StyleableObjectProperty; import javafx.css.StyleableProperty; import javafx.css.converter.InsetsConverter; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.Label; import javafx.scene.control.Skin; import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; // TODO: We plan to replace ComponentList with this class, but we need to address some issues first /// @author Glavo public final class OptionsList extends Control { public OptionsList() { this.getStyleClass().add("options-list"); } @Override protected Skin createDefaultSkin() { return new OptionsListSkin(this); } private final ObservableList elements = FXCollections.observableArrayList(); public ObservableList getElements() { return elements; } public void addTitle(String title) { elements.add(new Title(title)); } public void addNode(Node node) { elements.add(new NodeElement(node)); } public void addListElement(@NotNull Node node) { elements.add(new ListElement(node)); } public void addListElements(@NotNull Node... nodes) { for (Node node : nodes) { elements.add(new ListElement(node)); } } private final StyleableObjectProperty contentPadding = new StyleableObjectProperty<>() { @Override public Object getBean() { return OptionsList.this; } @Override public String getName() { return "contentPadding"; } @Override public javafx.css.CssMetaData getCssMetaData() { return StyleableProperties.CONTENT_PADDING; } }; public StyleableObjectProperty contentPaddingProperty() { return contentPadding; } public Insets getContentPadding() { return contentPaddingProperty().get(); } public void setContentPadding(Insets padding) { contentPaddingProperty().set(padding); } private static final class StyleableProperties { private static final CssMetaData CONTENT_PADDING = new CssMetaData<>("-jfx-content-padding", InsetsConverter.getInstance()) { @Override public boolean isSettable(OptionsList styleable) { return styleable.contentPadding == null || !styleable.contentPadding.isBound(); } @Override public StyleableProperty getStyleableProperty(OptionsList styleable) { return styleable.contentPaddingProperty(); } }; private static final List> STYLEABLES; static { List> styleables = new ArrayList<>(Control.getClassCssMetaData()); Collections.addAll(styleables, CONTENT_PADDING); STYLEABLES = List.copyOf(styleables); } } public static List> getClassCssMetaData() { return StyleableProperties.STYLEABLES; } @Override public List> getControlCssMetaData() { return getClassCssMetaData(); } public static abstract class Element { protected Node node; Node getNode() { if (node == null) node = createNode(); return node; } protected abstract Node createNode(); } public static final class Title extends Element { private final @NotNull String title; public Title(@NotNull String title) { this.title = title; } @Override protected Node createNode() { Label label = new Label(title); label.setPadding(new Insets(8, 0, 8, 0)); return label; } @Override public boolean equals(Object obj) { return this == obj || obj instanceof Title that && Objects.equals(this.title, that.title); } @Override public int hashCode() { return title.hashCode(); } @Override public String toString() { return "Title[%s]".formatted(title); } } public static final class NodeElement extends Element { public NodeElement(@NotNull Node node) { this.node = node; } @Override protected Node createNode() { return node; } @Override public boolean equals(Object obj) { return this == obj || obj instanceof NodeElement that && this.node.equals(that.node); } @Override public int hashCode() { return node.hashCode(); } @Override public String toString() { return "NodeElement[node=%s]".formatted(node); } } public static final class ListElement extends Element { private final Node original; public ListElement(@NotNull Node node) { this.original = node; } @Override protected Node createNode() { if (original instanceof ComponentSublist sublist) { return new ComponentSublistWrapper(sublist); } else { return original; } } @Override public boolean equals(Object obj) { return this == obj || obj instanceof ListElement that && this.original.equals(that.original); } @Override public int hashCode() { return original.hashCode(); } @Override public String toString() { return "ListElement[node=%s]".formatted(original); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/OptionsListSkin.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2026 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXListView; import javafx.beans.InvalidationListener; import javafx.beans.WeakInvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.ListCell; import javafx.scene.control.SkinBase; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.ui.FXUtils; /// @author Glavo public final class OptionsListSkin extends SkinBase { private final JFXListView listView; private final ObjectBinding contentPaddings; OptionsListSkin(OptionsList control) { super(control); this.listView = new JFXListView<>(); listView.setItems(control.getElements()); listView.setCellFactory(listView1 -> new Cell()); this.contentPaddings = Bindings.createObjectBinding(() -> { Insets padding = control.getContentPadding(); return padding == null ? ContentPaddings.EMPTY : new ContentPaddings( new Insets(padding.getTop(), padding.getRight(), 0, padding.getLeft()), new Insets(0, padding.getRight(), padding.getBottom(), padding.getLeft()), new Insets(0, padding.getRight(), 0, padding.getLeft()) ); }, control.contentPaddingProperty()); this.getChildren().setAll(listView); } private record ContentPaddings(Insets first, Insets last, Insets middle) { static final ContentPaddings EMPTY = new ContentPaddings(Insets.EMPTY, Insets.EMPTY, Insets.EMPTY); } private final class Cell extends ListCell { private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first"); private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last"); @SuppressWarnings("FieldCanBeLocal") private final InvalidationListener updateStyleListener = o -> updateStyle(); private StackPane wrapper; public Cell() { FXUtils.limitCellWidth(listView, this); WeakInvalidationListener weakListener = new WeakInvalidationListener(updateStyleListener); listView.itemsProperty().addListener((o, oldValue, newValue) -> { if (oldValue != null) oldValue.removeListener(weakListener); if (newValue != null) newValue.addListener(weakListener); weakListener.invalidated(o); }); itemProperty().addListener(weakListener); contentPaddings.addListener(weakListener); } @Override protected void updateItem(OptionsList.Element item, boolean empty) { super.updateItem(item, empty); if (empty || item == null) { setGraphic(null); } else if (item instanceof OptionsList.ListElement element) { if (wrapper == null) wrapper = createWrapper(); else wrapper.getStyleClass().remove("no-padding"); Node node = element.getNode(); if (node instanceof NoPaddingComponent || node.getProperties().containsKey("ComponentList.noPadding")) wrapper.getStyleClass().add("no-padding"); wrapper.getChildren().setAll(node); setGraphic(wrapper); } else { setGraphic(item.getNode()); } updateStyle(); } private StackPane createWrapper() { var wrapper = new StackPane(); wrapper.setAlignment(Pos.CENTER_LEFT); wrapper.getStyleClass().add("options-list-item"); updateStyle(); return wrapper; } private void updateStyle() { OptionsList.Element item = getItem(); int index = getIndex(); ObservableList items = getListView().getItems(); if (item == null || index < 0 || index >= items.size()) { this.setPadding(Insets.EMPTY); return; } boolean isFirst = index == 0; boolean isLast = index == items.size() - 1; ContentPaddings paddings = contentPaddings.get(); if (isFirst) { this.setPadding(paddings.first); } else if (isLast) { this.setPadding(paddings.last); } else { this.setPadding(paddings.middle); } if (item instanceof OptionsList.ListElement && wrapper != null) { wrapper.pseudoClassStateChanged(PSEUDO_CLASS_FIRST, isFirst || !(items.get(index - 1) instanceof OptionsList.ListElement)); wrapper.pseudoClassStateChanged(PSEUDO_CLASS_LAST, isLast || !(items.get(index + 1) instanceof OptionsList.ListElement)); } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PageAware.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; public interface PageAware { default void onPageShown() { } default void onPageHidden() { } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PageCloseEvent.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.event.Event; import javafx.event.EventTarget; import javafx.event.EventType; /** * Indicates a close operation on the navigator page. * * @author huangyuhui */ public class PageCloseEvent extends Event { public static final EventType CLOSE = new EventType<>("PAGE_CLOSE"); public PageCloseEvent() { super(CLOSE); } public PageCloseEvent(Object source, EventTarget target) { super(source, target, CLOSE); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PopupMenu.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.binding.Bindings; import javafx.beans.binding.When; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.ScrollPane; import javafx.scene.control.Skin; import javafx.scene.control.SkinBase; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; public class PopupMenu extends Control { private final ObservableList content = FXCollections.observableArrayList(); private final BooleanProperty alwaysShowingVBar = new SimpleBooleanProperty(); public PopupMenu() { getStyleClass().add("popup-menu"); } public ObservableList getContent() { return content; } public boolean isAlwaysShowingVBar() { return alwaysShowingVBar.get(); } public BooleanProperty alwaysShowingVBarProperty() { return alwaysShowingVBar; } public void setAlwaysShowingVBar(boolean alwaysShowingVBar) { this.alwaysShowingVBar.set(alwaysShowingVBar); } @Override protected Skin createDefaultSkin() { return new PopupMenuSkin(); } public static Node wrapPopupMenuItem(Node node) { StackPane pane = new StackPane(); pane.getChildren().setAll(node); pane.getStyleClass().add("menu-container"); node.setMouseTransparent(true); return new RipplerContainer(pane); } private class PopupMenuSkin extends SkinBase { protected PopupMenuSkin() { super(PopupMenu.this); ScrollPane scrollPane = new ScrollPane(); scrollPane.setFitToHeight(true); scrollPane.setFitToWidth(true); scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); scrollPane.vbarPolicyProperty().bind(new When(alwaysShowingVBar) .then(ScrollPane.ScrollBarPolicy.ALWAYS) .otherwise(ScrollPane.ScrollBarPolicy.AS_NEEDED)); VBox content = new VBox(); content.getStyleClass().add("popup-menu-content"); Bindings.bindContent(content.getChildren(), PopupMenu.this.getContent()); scrollPane.setContent(content); FXUtils.smoothScrolling(scrollPane); getChildren().setAll(scrollPane); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/PromptDialogPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXCheckBox; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXTextField; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.geometry.Insets; import javafx.scene.control.Label; import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; import javafx.scene.layout.HBox; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FutureCallback; import org.jackhuang.hmcl.util.StringUtils; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.concurrent.CompletableFuture; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; public class PromptDialogPane extends DialogPane { private final CompletableFuture>> future = new CompletableFuture<>(); private final Builder builder; public PromptDialogPane(Builder builder) { this.builder = builder; setTitle(builder.title); setPrefWidth(560); GridPane body = new GridPane(); body.setVgap(8); body.setHgap(16); body.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing()); setBody(body); List bindings = new ArrayList<>(); int rowIndex = 0; for (Builder.Question question : builder.questions) { if (question instanceof Builder.StringQuestion) { Builder.StringQuestion stringQuestion = (Builder.StringQuestion) question; JFXTextField textField = new JFXTextField(); textField.textProperty().addListener((a, b, newValue) -> stringQuestion.value = textField.getText()); textField.setText(stringQuestion.value); textField.setValidators(((Builder.StringQuestion) question).validators.toArray(new ValidatorBase[0])); if (stringQuestion.promptText != null) { textField.setPromptText(stringQuestion.promptText); } bindings.add(Bindings.createBooleanBinding(textField::validate, textField.textProperty())); if (StringUtils.isNotBlank(question.question.get())) { body.addRow(rowIndex++, new Label(question.question.get()), textField); } else { GridPane.setColumnSpan(textField, 1); body.addRow(rowIndex++, textField); } GridPane.setMargin(textField, new Insets(0, 0, 20, 0)); } else if (question instanceof Builder.BooleanQuestion) { HBox hBox = new HBox(); GridPane.setColumnSpan(hBox, 1); JFXCheckBox checkBox = new JFXCheckBox(); hBox.getChildren().setAll(checkBox); HBox.setMargin(checkBox, new Insets(0, 0, 0, -10)); checkBox.setSelected(((Builder.BooleanQuestion) question).value); checkBox.selectedProperty().addListener((a, b, newValue) -> ((Builder.BooleanQuestion) question).value = newValue); checkBox.setText(question.question.get()); body.addRow(rowIndex++, hBox); } else if (question instanceof Builder.CandidatesQuestion) { JFXComboBox comboBox = new JFXComboBox<>(); comboBox.getItems().setAll(((Builder.CandidatesQuestion) question).candidates); comboBox.getSelectionModel().selectedIndexProperty().addListener((a, b, newValue) -> ((Builder.CandidatesQuestion) question).value = newValue.intValue()); comboBox.getSelectionModel().select(0); if (StringUtils.isNotBlank(question.question.get())) { body.addRow(rowIndex++, new Label(question.question.get()), comboBox); } else { GridPane.setColumnSpan(comboBox, 1); body.addRow(rowIndex++, comboBox); } } else if (question instanceof Builder.HintQuestion) { HintPane pane = new HintPane(); GridPane.setColumnSpan(pane, 1); pane.textProperty().bind(question.question); body.addRow(rowIndex++, pane); } } validProperty().bind(Bindings.createBooleanBinding( () -> bindings.stream().allMatch(BooleanBinding::get), bindings.toArray(new BooleanBinding[0]) )); } @Override protected void onAccept() { setLoading(); builder.callback.call(builder.questions, new FutureCallback.ResultHandler() { @Override public void resolve() { future.complete(builder.questions); runInFX(() -> onSuccess()); } @Override public void reject(String reason) { runInFX(() -> onFailure(reason)); } }); } public CompletableFuture>> getCompletableFuture() { return future; } public static class Builder { private final List> questions = new ArrayList<>(); private final String title; private final FutureCallback>> callback; public Builder(String title, FutureCallback>> callback) { this.title = title; this.callback = callback; } public Builder addQuestion(Question question) { questions.add(question); return this; } public static class Question { public final StringProperty question = new SimpleStringProperty(); protected T value; public Question(String question) { this.question.set(question); } public T getValue() { return value; } public String getQuestion() { return question.get(); } public StringProperty questionProperty() { return question; } public void setQuestion(String question) { this.question.set(question); } } public static class HintQuestion extends Question { public HintQuestion(String hint) { super(hint); } } public static class StringQuestion extends Question { protected final List validators; protected String promptText; public StringQuestion(String question, String defaultValue, ValidatorBase... validators) { super(question); this.value = defaultValue; this.validators = Arrays.asList(validators); } public StringQuestion setPromptText(String promptText) { this.promptText = promptText; return this; } } public static class CandidatesQuestion extends Question { protected final List candidates; public CandidatesQuestion(String question, String... candidates) { super(question); this.value = null; if (candidates == null || candidates.length == 0) { throw new IllegalArgumentException("At least one candidate required"); } this.candidates = new ArrayList<>(Arrays.asList(candidates)); } } public static class BooleanQuestion extends Question { public BooleanQuestion(String question, boolean defaultValue) { super(question); this.value = defaultValue; } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RequiredValidator.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.validation.base.ValidatorBase; import javafx.beans.NamedArg; import javafx.scene.control.TextInputControl; import org.jackhuang.hmcl.util.StringUtils; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class RequiredValidator extends ValidatorBase { public RequiredValidator() { this(i18n("input.not_empty")); } public RequiredValidator(@NamedArg("message") String message) { super(message); } @Override protected void eval() { if (srcControl.get() instanceof TextInputControl) { evalTextInputField(); } } private void evalTextInputField() { TextInputControl textField = ((TextInputControl) srcControl.get()); hasErrors.set(StringUtils.isBlank(textField.getText())); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/RipplerContainer.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXRippler; import javafx.animation.Transition; import javafx.css.*; import javafx.css.converter.PaintConverter; import javafx.event.EventHandler; import javafx.geometry.Insets; import javafx.scene.Node; import javafx.scene.input.MouseEvent; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.paint.Paint; import javafx.scene.shape.Rectangle; import org.jackhuang.hmcl.theme.Themes; import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.animation.Motion; import java.util.ArrayList; import java.util.List; public class RipplerContainer extends StackPane { private static final String DEFAULT_STYLE_CLASS = "rippler-container"; private static final CornerRadii DEFAULT_RADII = new CornerRadii(3); private static final Color DEFAULT_RIPPLER_FILL = Color.rgb(0, 200, 255); private final Node container; private final StackPane buttonContainer = new StackPane(); private final JFXRippler buttonRippler = new JFXRippler(new StackPane()) { private static final Background DEFAULT_MASK_BACKGROUND = new Background(new BackgroundFill(Color.WHITE, DEFAULT_RADII, Insets.EMPTY)); @Override protected Node getMask() { StackPane mask = new StackPane(); mask.shapeProperty().bind(buttonContainer.shapeProperty()); mask.setBackground(DEFAULT_MASK_BACKGROUND); mask.resize( buttonContainer.getWidth() - buttonContainer.snappedRightInset() - buttonContainer.snappedLeftInset(), buttonContainer.getHeight() - buttonContainer.snappedBottomInset() - buttonContainer.snappedTopInset() ); return mask; } }; private Transition coverAnimation; public RipplerContainer(Node container) { this.container = container; getStyleClass().add(DEFAULT_STYLE_CLASS); buttonRippler.setPosition(JFXRippler.RipplerPos.BACK); buttonContainer.getChildren().add(buttonRippler); focusedProperty().addListener((a, b, newValue) -> { if (newValue) { if (!isPressed()) buttonRippler.showOverlay(); } else { buttonRippler.hideOverlay(); } }); pressedProperty().addListener(o -> buttonRippler.hideOverlay()); setPickOnBounds(false); buttonContainer.setPickOnBounds(false); updateChildren(); var shape = new Rectangle(); shape.widthProperty().bind(widthProperty()); shape.heightProperty().bind(heightProperty()); setShape(shape); EventHandler mouseEventHandler; if (AnimationUtils.isAnimationEnabled()) { mouseEventHandler = event -> { if (coverAnimation != null) { coverAnimation.stop(); coverAnimation = null; } if (event.getEventType() == MouseEvent.MOUSE_ENTERED) { coverAnimation = new Transition() { { setCycleDuration(Motion.SHORT4); setInterpolator(Motion.EASE_IN); } @Override protected void interpolate(double frac) { interpolateBackground(frac); } }; } else { coverAnimation = new Transition() { { setCycleDuration(Motion.SHORT4); setInterpolator(Motion.EASE_OUT); } @Override protected void interpolate(double frac) { interpolateBackground(1 - frac); } }; } coverAnimation.play(); }; } else { mouseEventHandler = event -> interpolateBackground(event.getEventType() == MouseEvent.MOUSE_ENTERED ? 1 : 0); } addEventHandler(MouseEvent.MOUSE_ENTERED, mouseEventHandler); addEventHandler(MouseEvent.MOUSE_EXITED, mouseEventHandler); } private void interpolateBackground(double frac) { if (frac < 0.01) { setBackground(null); } else { Color onSurface = Themes.getColorScheme().getOnSurface(); setBackground(new Background(new BackgroundFill( Color.color(onSurface.getRed(), onSurface.getGreen(), onSurface.getBlue(), frac * 0.04), CornerRadii.EMPTY, Insets.EMPTY))); } } protected void updateChildren() { Node container = getContainer(); if (buttonRippler.getPosition() == JFXRippler.RipplerPos.BACK) { getChildren().setAll(buttonContainer, container); container.setPickOnBounds(false); } else { getChildren().setAll(container, buttonContainer); buttonContainer.setPickOnBounds(false); } } public void setPosition(JFXRippler.RipplerPos pos) { buttonRippler.setPosition(pos); updateChildren(); } public JFXRippler getRippler() { return buttonRippler; } public Node getContainer() { return container; } private final StyleableObjectProperty ripplerFill = new StyleableObjectProperty<>(DEFAULT_RIPPLER_FILL) { @Override public Object getBean() { return RipplerContainer.this; } @Override public String getName() { return "ripplerFill"; } @Override public CssMetaData getCssMetaData() { return StyleableProperties.RIPPLER_FILL; } @Override protected void invalidated() { buttonRippler.setRipplerFill(get()); } }; public StyleableObjectProperty ripplerFillProperty() { return ripplerFill; } public Paint getRipplerFill() { return ripplerFillProperty().get(); } public void setRipplerFill(Paint ripplerFill) { ripplerFillProperty().set(ripplerFill); } @Override public List> getCssMetaData() { return getClassCssMetaData(); } public static List> getClassCssMetaData() { return StyleableProperties.STYLEABLES; } private final static class StyleableProperties { private static final CssMetaData RIPPLER_FILL = new CssMetaData<>("-jfx-rippler-fill", PaintConverter.getInstance(), DEFAULT_RIPPLER_FILL) { @Override public boolean isSettable(RipplerContainer styleable) { return styleable.ripplerFill == null || !styleable.ripplerFill.isBound(); } @Override public StyleableProperty getStyleableProperty(RipplerContainer styleable) { return styleable.ripplerFillProperty(); } }; private static final List> STYLEABLES; static { var styleables = new ArrayList<>(StackPane.getClassCssMetaData()); styleables.add(RIPPLER_FILL); STYLEABLES = List.copyOf(styleables); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/SpinnerPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXSpinner; import javafx.beans.property.*; import javafx.event.Event; import javafx.event.EventHandler; import javafx.event.EventType; import javafx.scene.Node; import javafx.scene.control.Control; import javafx.scene.control.Label; import javafx.scene.control.SkinBase; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.TransitionPane; /// A spinner pane that can show spinner, failed reason, or content. public class SpinnerPane extends Control { private static final String DEFAULT_STYLE_CLASS = "spinner-pane"; public SpinnerPane() { getStyleClass().add(DEFAULT_STYLE_CLASS); } public void showSpinner() { setLoading(true); } public void hideSpinner() { setFailedReason(null); setLoading(false); } private void updateContent() { if (getSkin() instanceof Skin skin) { skin.updateContent(); } } private ObjectProperty content; public ObjectProperty contentProperty() { if (content == null) content = new ObjectPropertyBase<>() { @Override public Object getBean() { return SpinnerPane.this; } @Override public String getName() { return "content"; } @Override protected void invalidated() { updateContent(); } }; return content; } public Node getContent() { return contentProperty().get(); } public void setContent(Node content) { contentProperty().set(content); } private BooleanProperty loading; public BooleanProperty loadingProperty() { if (loading == null) loading = new BooleanPropertyBase() { @Override public Object getBean() { return SpinnerPane.this; } @Override public String getName() { return "loading"; } @Override protected void invalidated() { updateContent(); } }; return loading; } public boolean isLoading() { return loading != null && loading.get(); } public void setLoading(boolean loading) { loadingProperty().set(loading); } private StringProperty failedReason; public StringProperty failedReasonProperty() { if (failedReason == null) failedReason = new StringPropertyBase() { @Override public Object getBean() { return SpinnerPane.this; } @Override public String getName() { return "failedReason"; } @Override protected void invalidated() { updateContent(); } }; return failedReason; } public String getFailedReason() { return failedReason != null ? failedReason.get() : null; } public void setFailedReason(String failedReason) { failedReasonProperty().set(failedReason); } private ObjectProperty> onFailedAction; public final ObjectProperty> onFailedActionProperty() { if (onFailedAction == null) { onFailedAction = new ObjectPropertyBase<>() { @Override public Object getBean() { return SpinnerPane.this; } @Override public String getName() { return "onFailedAction"; } @Override protected void invalidated() { setEventHandler(FAILED_ACTION, get()); } }; } return onFailedAction; } public final EventHandler getOnFailedAction() { return onFailedAction != null ? onFailedAction.get() : null; } public final void setOnFailedAction(EventHandler value) { onFailedActionProperty().set(value); } @Override protected SkinBase createDefaultSkin() { return new Skin(this); } private static final class Skin extends SkinBase { private final TransitionPane root = new TransitionPane(); Skin(SpinnerPane control) { super(control); root.setClip(null); updateContent(); this.getChildren().setAll(root); } private StackPane contentPane; private StackPane spinnerPane; private StackPane failedPane; private Label failedReasonLabel; void updateContent() { SpinnerPane control = getSkinnable(); Node nextContent; if (control.isLoading()) { if (spinnerPane == null) { spinnerPane = new StackPane(new JFXSpinner()); spinnerPane.getStyleClass().add("notice-pane"); } nextContent = spinnerPane; } else if (control.getFailedReason() != null) { if (failedPane == null) { failedReasonLabel = new Label(); failedPane = new StackPane(failedReasonLabel); failedPane.getStyleClass().add("notice-pane"); FXUtils.onClicked(failedPane, () -> control.fireEvent(new Event(SpinnerPane.FAILED_ACTION))); } failedReasonLabel.setText(control.getFailedReason()); nextContent = failedPane; } else { if (contentPane == null) { contentPane = new StackPane(); } Node content = control.getContent(); if (content != null) contentPane.getChildren().setAll(content); else contentPane.getChildren().clear(); nextContent = contentPane; } if (nextContent != failedPane && failedReasonLabel != null) { failedReasonLabel.setText(null); } root.setContent(nextContent, ContainerAnimations.FADE); } } public static final EventType FAILED_ACTION = new EventType<>(Event.ANY, "FAILED_ACTION"); } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabControl.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ 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; } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TabHeader.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.animation.*; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Side; import javafx.scene.Node; import javafx.scene.control.*; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.scene.transform.Rotate; import javafx.scene.transform.Scale; import javafx.util.Duration; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.Motion; import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.util.javafx.MappedObservableList; import org.jetbrains.annotations.Nullable; @SuppressWarnings("deprecation") public class TabHeader extends Control implements TabControl, PageAware { private final TransitionPane contentPane; public TabHeader(Tab... tabs) { this(null, tabs); } public TabHeader(@Nullable TransitionPane contentPane, Tab... tabs) { this.contentPane = contentPane; getStyleClass().setAll("tab-header"); if (tabs != null) { getTabs().addAll(tabs); } } private final ObservableList> tabs = FXCollections.observableArrayList(); @Override public ObservableList> getTabs() { return tabs; } private final SingleSelectionModel> selectionModel = new TabControlSelectionModel(this); public SingleSelectionModel> getSelectionModel() { return selectionModel; } public void select(Tab tab) { select(tab, true); } public void select(Tab tab, boolean playAnimation) { Tab oldTab = getSelectionModel().getSelectedItem(); if (oldTab != null) { if (oldTab.getNode() instanceof PageAware pageAware) { pageAware.onPageHidden(); } } tab.initializeIfNeeded(); if (tab.getNode() instanceof PageAware pageAware) { pageAware.onPageShown(); } getSelectionModel().select(tab); if (contentPane != null) { if (playAnimation && contentPane.getCurrentNode() != null) { contentPane.setContent(tab.getNode(), ContainerAnimations.SLIDE_UP_FADE_IN, Motion.MEDIUM4, Motion.EASE_IN_OUT_CUBIC_EMPHASIZED ); } else { contentPane.setContent(tab.getNode(), ContainerAnimations.NONE); } } } @Override public void onPageShown() { Tab tab = getSelectionModel().getSelectedItem(); if (tab != null) { if (tab.getNode() instanceof PageAware) { ((PageAware) tab.getNode()).onPageShown(); } } } @Override public void onPageHidden() { Tab tab = getSelectionModel().getSelectedItem(); if (tab != null) { if (tab.getNode() instanceof PageAware) { ((PageAware) tab.getNode()).onPageHidden(); } } } private final ObjectProperty side = new SimpleObjectProperty<>(Side.TOP); /** * The position of the tabs. */ public ObjectProperty sideProperty() { return side; } /** * The position to place the tabs. */ public Side getSide() { return side.get(); } /** * The position the place the tabs in this TabHeader. */ public void setSide(Side side) { this.side.set(side); } @Override protected Skin createDefaultSkin() { return new TabHeaderSkin(this); } public static class TabHeaderSkin extends SkinBase { private static final PseudoClass SELECTED_PSEUDOCLASS_STATE = PseudoClass.getPseudoClass("selected"); private final HeaderContainer header; private boolean isSelectingTab = false; private Tab selectedTab; 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(); }); }); this.selectedTab = control.getSelectionModel().getSelectedItem(); if (this.selectedTab == null && control.getSelectionModel().getSelectedIndex() != -1) { control.getSelectionModel().select(control.getSelectionModel().getSelectedIndex()); this.selectedTab = control.getSelectionModel().getSelectedItem(); } if (this.selectedTab == null) { control.getSelectionModel().selectFirst(); } this.selectedTab = control.getSelectionModel().getSelectedItem(); } protected final class HeaderContainer extends StackPane { private Timeline timeline; private final StackPane selectedTabLine; private final HeadersRegion headersRegion; private final Scale scale = new Scale(1, 1, 0, 0); private final Rotate rotate = new Rotate(0, 0, 1); @SuppressWarnings({"FieldCanBeLocal", "unused"}) private final ObservableList binding; public HeaderContainer() { getStyleClass().add("tab-header-area"); setPickOnBounds(false); headersRegion = new HeadersRegion(); headersRegion.sideProperty().bind(getSkinnable().sideProperty()); selectedTabLine = new StackPane(); selectedTabLine.setManaged(false); selectedTabLine.getTransforms().addAll(scale, rotate); selectedTabLine.setCache(true); selectedTabLine.getStyleClass().addAll("tab-selected-line"); selectedTabLine.setPrefHeight(2); selectedTabLine.setPrefWidth(2); getChildren().setAll(headersRegion, selectedTabLine); headersRegion.setPickOnBounds(false); headersRegion.prefHeightProperty().bind(heightProperty()); rotate.pivotXProperty().bind(Bindings.createDoubleBinding(() -> getSkinnable().getSide().isHorizontal() ? 0.0 : 1, getSkinnable().sideProperty())); rotate.pivotYProperty().bind(Bindings.createDoubleBinding(() -> getSkinnable().getSide().isHorizontal() ? 1.0 : 0, getSkinnable().sideProperty())); Bindings.bindContent(headersRegion.getChildren(), binding = MappedObservableList.create(getSkinnable().getTabs(), tab -> { TabHeaderContainer container = new TabHeaderContainer(tab); container.setVisible(true); return container; })); } public void setNeedsLayout2(boolean value) { setNeedsLayout(value); } private boolean isAnimating() { return this.timeline != null && this.timeline.getStatus() == Animation.Status.RUNNING; } @Override protected void layoutChildren() { super.layoutChildren(); if (isSelectingTab) { headersRegion.animateSelectionLine(); isSelectingTab = false; } } private final class HeadersRegion extends StackPane { private SideAction action; private final ObjectProperty side = new SimpleObjectProperty() { @Override protected void invalidated() { super.invalidated(); action = switch (get()) { case TOP -> new Top(); case BOTTOM -> new Bottom(); case LEFT -> new Left(); case RIGHT -> new Right(); }; } }; public Side getSide() { return side.get(); } public ObjectProperty sideProperty() { return side; } public void setSide(Side side) { this.side.set(side); } @Override protected double computePrefWidth(double height) { return action.computePrefWidth(height); } @Override protected double computePrefHeight(double width) { return action.computePrefHeight(width); } @Override protected void layoutChildren() { action.layoutChildren(); } private void animateSelectionLine() { action.animateSelectionLine(); } private abstract class SideAction { abstract double computePrefWidth(double height); abstract double computePrefHeight(double width); void layoutChildren() { if (isSelectingTab) { animateSelectionLine(); isSelectingTab = false; } } abstract void animateSelectionLine(); } private abstract class Horizontal extends SideAction { @Override public double computePrefWidth(double height) { double width = 0; for (Node child : getChildren()) { if (!(child instanceof TabHeaderContainer) || !child.isVisible()) continue; width += child.prefWidth(height); } return snapSize(width) + snappedLeftInset() + snappedRightInset(); } @Override public double computePrefHeight(double width) { double height = 0; for (Node child : getChildren()) { if (!(child instanceof TabHeaderContainer) || !child.isVisible()) continue; height = Math.max(height, child.prefHeight(width)); } return snapSize(height) + snappedTopInset() + snappedBottomInset(); } private void runTimeline(double newTransX, double newWidth) { double lineWidth = selectedTabLine.prefWidth(-1.0D); if (isAnimating()) { timeline.stop(); double tempScaleX = scale.getX(); if (rotate.getAngle() != 0.0D) { rotate.setAngle(0.0D); double tempWidth = tempScaleX * lineWidth; selectedTabLine.setTranslateX(selectedTabLine.getTranslateX() - tempWidth); } } double oldScaleX = scale.getX(); double oldWidth = lineWidth * oldScaleX; double oldTransX = selectedTabLine.getTranslateX(); double newScaleX = newWidth * oldScaleX / oldWidth; // newTransX += offsetStart * (double)this.direction; double transDiff = newTransX - oldTransX; if (transDiff < 0.0D) { selectedTabLine.setTranslateX(selectedTabLine.getTranslateX() + oldWidth); newTransX += newWidth; rotate.setAngle(180.0D); } timeline = new Timeline( new KeyFrame( Duration.ZERO, new KeyValue(selectedTabLine.translateXProperty(), selectedTabLine.getTranslateX(), Interpolator.EASE_BOTH) ), new KeyFrame( Duration.seconds(0.24D), new KeyValue(scale.xProperty(), newScaleX, Interpolator.EASE_BOTH), new KeyValue(selectedTabLine.translateXProperty(), newTransX, Interpolator.EASE_BOTH) ) ); timeline.setOnFinished((finish) -> { if (rotate.getAngle() != 0.0D) { rotate.setAngle(0.0D); selectedTabLine.setTranslateX(selectedTabLine.getTranslateX() - newWidth); } }); timeline.play(); } @Override public void animateSelectionLine() { double offset = 0.0D; double selectedTabOffset = 0.0D; double selectedTabWidth = 0.0D; for (Node node : headersRegion.getChildren()) { if (node instanceof TabHeaderContainer) { TabHeaderContainer tabHeader = (TabHeaderContainer) node; double tabHeaderPrefWidth = snapSize(tabHeader.prefWidth(-1.0D)); if (selectedTab != null && selectedTab.equals(tabHeader.tab)) { selectedTabOffset = offset; selectedTabWidth = tabHeaderPrefWidth; break; } offset += tabHeaderPrefWidth; } } this.runTimeline(selectedTabOffset, selectedTabWidth); } } private final class Top extends Horizontal { @Override public void layoutChildren() { super.layoutChildren(); double headerHeight = snapSize(prefHeight(-1)); double tabStartX = 0; for (Node node : getChildren()) { if (!(node instanceof TabHeaderContainer)) continue; TabHeaderContainer child = (TabHeaderContainer) node; double w = snapSize(child.prefWidth(-1)); double h = snapSize(child.prefHeight(-1)); child.resize(w, h); child.relocate(tabStartX, headerHeight - h - snappedBottomInset()); tabStartX += w; } selectedTabLine.resizeRelocate(0, headerHeight - selectedTabLine.prefHeight(-1), snapSize(selectedTabLine.prefWidth(-1)), snapSize(selectedTabLine.prefHeight(-1))); } } private final class Bottom extends Horizontal { @Override public void layoutChildren() { super.layoutChildren(); double headerHeight = snapSize(prefHeight(-1)); double tabStartX = 0; for (Node node : getChildren()) { if (!(node instanceof TabHeaderContainer)) continue; TabHeaderContainer child = (TabHeaderContainer) node; double w = snapSize(child.prefWidth(-1)); double h = snapSize(child.prefHeight(-1)); child.resize(w, h); child.relocate(tabStartX, snappedTopInset()); tabStartX += w; } selectedTabLine.resizeRelocate(0, 0, snapSize(selectedTabLine.prefWidth(-1)), snapSize(selectedTabLine.prefHeight(-1))); } } private abstract class Vertical extends SideAction { @Override public double computePrefWidth(double height) { double width = 0; for (Node child : getChildren()) { if (!(child instanceof TabHeaderContainer) || !child.isVisible()) continue; width = Math.max(width, child.prefWidth(height)); } return snapSize(width) + snappedLeftInset() + snappedRightInset(); } @Override public double computePrefHeight(double width) { double height = 0; for (Node child : getChildren()) { if (!(child instanceof TabHeaderContainer) || !child.isVisible()) continue; height += child.prefHeight(width); } return snapSize(height) + snappedTopInset() + snappedBottomInset(); } private void runTimeline(double newTransY, double newHeight) { double lineHeight = selectedTabLine.prefHeight(-1.0D); if (isAnimating()) { timeline.stop(); double tempScaleY = scale.getY(); if (rotate.getAngle() != 0.0D) { rotate.setAngle(0.0D); double tempHeight = tempScaleY * lineHeight; selectedTabLine.setTranslateY(selectedTabLine.getTranslateY() - tempHeight); } } double oldScaleY = scale.getY(); double oldHeight = lineHeight * oldScaleY; double oldTransY = selectedTabLine.getTranslateY(); double newScaleY = newHeight * oldScaleY / oldHeight; // newTransY += offsetStart * (double)this.direction; double transDiff = newTransY - oldTransY; if (transDiff < 0.0D) { selectedTabLine.setTranslateY(selectedTabLine.getTranslateY() + oldHeight); newTransY += newHeight; rotate.setAngle(180.0D); } timeline = new Timeline( new KeyFrame( Duration.ZERO, new KeyValue(selectedTabLine.translateYProperty(), selectedTabLine.getTranslateY(), Interpolator.EASE_BOTH) ), new KeyFrame( Duration.seconds(1.24D), new KeyValue(scale.yProperty(), newScaleY, Interpolator.EASE_BOTH), new KeyValue(selectedTabLine.translateYProperty(), newTransY, Interpolator.EASE_BOTH) ) ); timeline.setOnFinished((finish) -> { if (rotate.getAngle() != 0.0D) { rotate.setAngle(0.0D); selectedTabLine.setTranslateY(selectedTabLine.getTranslateY() - newHeight); } }); timeline.play(); } @Override public void animateSelectionLine() { double offset = 0.0D; double selectedTabOffset = 0.0D; double selectedTabHeight = 0.0D; for (Node node : headersRegion.getChildren()) { if (node instanceof TabHeaderContainer) { TabHeaderContainer tabHeader = (TabHeaderContainer) node; double tabHeaderPrefHeight = snapSize(tabHeader.prefHeight(-1.0D)); if (selectedTab != null && selectedTab.equals(tabHeader.tab)) { selectedTabOffset = offset; selectedTabHeight = tabHeaderPrefHeight; break; } offset += tabHeaderPrefHeight; } } this.runTimeline(selectedTabOffset, selectedTabHeight); } } private final class Left extends Vertical { @Override public void layoutChildren() { super.layoutChildren(); double headerWidth = snapSize(prefWidth(-1)); double tabStartY = 0; for (Node node : getChildren()) { if (!(node instanceof TabHeaderContainer)) continue; TabHeaderContainer child = (TabHeaderContainer) node; double w = snapSize(child.prefWidth(-1)); double h = snapSize(child.prefHeight(-1)); child.resize(w, h); child.relocate(headerWidth - w - snappedRightInset(), tabStartY); tabStartY += h; } selectedTabLine.resizeRelocate(headerWidth - selectedTabLine.prefWidth(-1), 0, snapSize(selectedTabLine.prefWidth(-1)), snapSize(selectedTabLine.prefHeight(-1))); } } 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))); } } } } protected class TabHeaderContainer extends StackPane { private final Tab tab; private final Label tabText; private final BorderPane inner; public 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); FXUtils.onChangeAndOperate(tab.selectedProperty(), selected -> inner.pseudoClassStateChanged(SELECTED_PSEUDOCLASS_STATE, selected)); FXUtils.onClicked(this, () -> { this.setOpacity(1); getSkinnable().getSelectionModel().select(tab); }); } } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskExecutorDialogPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXButton; import javafx.application.Platform; import javafx.beans.property.StringProperty; import javafx.geometry.Insets; import javafx.scene.control.Label; import javafx.scene.layout.BorderPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.task.*; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.i18n.I18n; import org.jetbrains.annotations.NotNull; import java.util.function.Consumer; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class TaskExecutorDialogPane extends BorderPane { private TaskExecutor executor; private TaskCancellationAction onCancel; @SuppressWarnings({"unused", "FieldCanBeLocal"}) private final Consumer speedEventHandler; private final Label lblTitle; private final Label lblProgress; private final JFXButton btnCancel; private final TaskListPane taskListPane; public TaskExecutorDialogPane(@NotNull TaskCancellationAction cancel) { this.getStyleClass().add("task-executor-dialog-layout"); FXUtils.setLimitWidth(this, 500); FXUtils.setLimitHeight(this, 300); VBox center = new VBox(); this.setCenter(center); center.setPadding(new Insets(16)); { lblTitle = new Label(); lblTitle.setStyle("-fx-font-size: 14px; -fx-font-weight: BOLD;"); taskListPane = new TaskListPane(); VBox.setVgrow(taskListPane, Priority.ALWAYS); center.getChildren().setAll(lblTitle, taskListPane); } BorderPane bottom = new BorderPane(); this.setBottom(bottom); bottom.setPadding(new Insets(0, 8, 8, 8)); { lblProgress = new Label(); bottom.setLeft(lblProgress); btnCancel = new JFXButton(i18n("button.cancel")); btnCancel.getStyleClass().add("dialog-cancel"); bottom.setRight(btnCancel); } setCancel(cancel); btnCancel.setOnAction(e -> { if (onCancel.getCancellationAction() != null) { if (executor != null) executor.cancel(); onCancel.getCancellationAction().accept(this); } }); speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> { String message = I18n.formatSpeed(speedEvent.getSpeed()); Platform.runLater(() -> lblProgress.setText(message)); }); onEscPressed(this, btnCancel::fire); } public void setExecutor(TaskExecutor executor) { setExecutor(executor, true); } public void setExecutor(TaskExecutor executor, boolean autoClose) { this.executor = executor; if (executor != null) { taskListPane.setExecutor(executor); if (autoClose) executor.addTaskListener(new TaskListener() { @Override public void onStop(boolean success, TaskExecutor executor) { Platform.runLater(() -> fireEvent(new DialogCloseEvent())); } }); } } public StringProperty titleProperty() { return lblTitle.textProperty(); } public String getTitle() { return lblTitle.getText(); } public void setTitle(String currentState) { lblTitle.setText(currentState); } public void setCancel(TaskCancellationAction onCancel) { this.onCancel = onCancel; runInFX(() -> btnCancel.setDisable(onCancel == null)); } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TaskListPane.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2021 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import com.jfoenix.controls.JFXListView; import com.jfoenix.controls.JFXProgressBar; import javafx.application.Platform; import javafx.beans.WeakListener; import javafx.beans.binding.Bindings; import javafx.beans.binding.DoubleBinding; import javafx.beans.property.*; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.control.ListCell; import javafx.scene.control.ProgressIndicator; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask; import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask; import org.jackhuang.hmcl.download.fabric.FabricInstallTask; import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask; import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask; import org.jackhuang.hmcl.download.game.GameAssetDownloadTask; import org.jackhuang.hmcl.download.game.GameInstallTask; import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask; import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask; import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask; import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask; import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask; import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask; import org.jackhuang.hmcl.download.quilt.QuiltAPIInstallTask; import org.jackhuang.hmcl.download.quilt.QuiltInstallTask; import org.jackhuang.hmcl.game.HMCLModpackInstallTask; import org.jackhuang.hmcl.java.JavaInstallTask; import org.jackhuang.hmcl.mod.MinecraftInstanceTask; import org.jackhuang.hmcl.mod.ModpackInstallTask; import org.jackhuang.hmcl.mod.ModpackUpdateTask; import org.jackhuang.hmcl.mod.curse.CurseCompletionTask; import org.jackhuang.hmcl.mod.curse.CurseInstallTask; import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackCompletionTask; import org.jackhuang.hmcl.mod.mcbbs.McbbsModpackExportTask; import org.jackhuang.hmcl.mod.modrinth.ModrinthCompletionTask; import org.jackhuang.hmcl.mod.modrinth.ModrinthInstallTask; import org.jackhuang.hmcl.mod.modrinth.ModrinthModpackExportTask; import org.jackhuang.hmcl.mod.multimc.MultiMCModpackExportTask; import org.jackhuang.hmcl.mod.multimc.MultiMCModpackInstallTask; import org.jackhuang.hmcl.mod.server.ServerModpackCompletionTask; import org.jackhuang.hmcl.mod.server.ServerModpackExportTask; import org.jackhuang.hmcl.mod.server.ServerModpackLocalInstallTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.task.TaskListener; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.util.FXThread; import org.jetbrains.annotations.NotNull; import java.lang.ref.WeakReference; import java.util.Collection; import java.util.HashMap; import java.util.Map; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.Lang.tryCast; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class TaskListPane extends StackPane { private static final Insets DEFAULT_PROGRESS_NODE_PADDING = new Insets(0, 0, 4, 0); private static final Insets STAGED_PROGRESS_NODE_PADDING = new Insets(0, 0, 4, 26); private TaskExecutor executor; private final JFXListView listView = new JFXListView<>(); private final Map, ProgressListNode> nodes = new HashMap<>(); private final Map stageNodes = new HashMap<>(); private final ObjectProperty progressNodePadding = new SimpleObjectProperty<>(Insets.EMPTY); private final DoubleProperty cellWidth = new SimpleDoubleProperty(); public TaskListPane() { listView.setPadding(new Insets(12, 0, 0, 0)); listView.setCellFactory(l -> new Cell()); listView.setSelectionModel(null); FXUtils.onChangeAndOperate(listView.widthProperty(), width -> { double w = width.doubleValue(); cellWidth.set(w <= 12.0 ? w : w - 12.0); }); getChildren().setAll(listView); } @FXThread private void addStagesHints(@NotNull Collection hints) { for (Task.StagesHint hint : hints) { StageNode node = stageNodes.get(hint.stage()); if (node == null) { node = new StageNode(hint.stage()); stageNodes.put(hint.stage(), node); listView.getItems().add(node); } for (String stage : hint.aliases()) { stageNodes.put(stage, node); } } } @FXThread private void updateProgressNodePadding() { progressNodePadding.set(stageNodes.isEmpty() ? DEFAULT_PROGRESS_NODE_PADDING : STAGED_PROGRESS_NODE_PADDING); } public void setExecutor(TaskExecutor executor) { this.executor = executor; executor.addTaskListener(new TaskListener() { @Override public void onStart() { Platform.runLater(() -> { stageNodes.clear(); listView.getItems().clear(); addStagesHints(executor.getHints()); updateProgressNodePadding(); }); } @Override public void onReady(Task task) { if (task instanceof Task.StagesHintTask) { Platform.runLater(() -> { addStagesHints(((Task.StagesHintTask) task).getHints()); updateProgressNodePadding(); }); } if (task.getStage() != null) { Platform.runLater(() -> { StageNode node = stageNodes.get(task.getStage()); if (node != null) node.begin(); }); } } @Override public void onRunning(Task task) { if (!task.getSignificance().shouldShow() || task.getName() == null) return; if (task instanceof GameAssetDownloadTask) { task.setName(i18n("assets.download_all")); } else if (task instanceof GameInstallTask) { if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game")) return; task.setName(i18n("install.installer.install", i18n("install.installer.game"))); } else if (task instanceof CleanroomInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom"))); } else if (task instanceof LegacyFabricInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.legacyfabric"))); } else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.forge"))); } else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.neoforge"))); } else if (task instanceof LiteLoaderInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.liteloader"))); } else if (task instanceof OptiFineInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.optifine"))); } else if (task instanceof FabricInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.fabric"))); } else if (task instanceof FabricAPIInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.fabric-api"))); } else if (task instanceof QuiltInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.quilt"))); } else if (task instanceof QuiltAPIInstallTask) { task.setName(i18n("install.installer.install", i18n("install.installer.quilt-api"))); } else if (task instanceof CurseCompletionTask || task instanceof ModrinthCompletionTask || task instanceof ServerModpackCompletionTask || task instanceof McbbsModpackCompletionTask) { task.setName(i18n("modpack.completion")); } else if (task instanceof ModpackInstallTask) { task.setName(i18n("modpack.installing")); } else if (task instanceof ModpackUpdateTask) { task.setName(i18n("modpack.update")); } else if (task instanceof CurseInstallTask) { task.setName(i18n("modpack.installing.given", i18n("modpack.type.curse"))); } else if (task instanceof MultiMCModpackInstallTask) { task.setName(i18n("modpack.installing.given", i18n("modpack.type.multimc"))); } else if (task instanceof ModrinthInstallTask) { task.setName(i18n("modpack.installing.given", i18n("modpack.type.modrinth"))); } else if (task instanceof ServerModpackLocalInstallTask) { task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server")); } else if (task instanceof HMCLModpackInstallTask) { task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl"))); } else if (task instanceof McbbsModpackExportTask || task instanceof MultiMCModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) { task.setName(i18n("modpack.export")); } else if (task instanceof MinecraftInstanceTask) { task.setName(i18n("modpack.scan")); } else if (task instanceof MojangJavaDownloadTask) { task.setName(i18n("download.java")); } else if (task instanceof JavaInstallTask) { task.setName(i18n("java.installing")); } Platform.runLater(() -> { ProgressListNode node = new ProgressListNode(task); nodes.put(task, node); StageNode stageNode = stageNodes.get(task.getInheritedStage()); listView.getItems().add(listView.getItems().indexOf(stageNode) + 1, node); }); } @Override public void onFinished(Task task) { Platform.runLater(() -> { if (task.getStage() != null) { StageNode stageNode = stageNodes.get(task.getStage()); if (stageNode != null) stageNode.succeed(); } ProgressListNode node = nodes.remove(task); if (node != null) { node.unbind(); listView.getItems().remove(node); } }); } @Override public void onFailed(Task task, Throwable throwable) { if (task.getStage() != null) { Platform.runLater(() -> { StageNode stageNode = stageNodes.get(task.getStage()); if (stageNode != null) stageNode.fail(); }); } ProgressListNode node = nodes.remove(task); if (node != null) Platform.runLater(() -> node.setThrowable(throwable)); } @Override public void onPropertiesUpdate(Task task) { if (task instanceof Task.CountTask) { runInFX(() -> { StageNode stageNode = stageNodes.get(((Task.CountTask) task).getCountStage()); if (stageNode != null) stageNode.count(); }); return; } if (task.getStage() != null) { int total = tryCast(task.getProperties().get("total"), Integer.class).orElse(0); runInFX(() -> { StageNode stageNode = stageNodes.get(task.getStage()); if (stageNode != null) stageNode.addTotal(total); }); } } }); } private final class Cell extends ListCell { private static final double STATUS_ICON_SIZE = 14; private static final Insets PROGRESS_BAR_MARGIN = new Insets(2, 0, 0, 0); private final BorderPane pane = new BorderPane(); private final StackPane left = new StackPane(); private final Label title = new Label(); private final Label message = new Label(); private final JFXProgressBar bar = new JFXProgressBar(); private WeakReference prevStageNodeRef; private StatusChangeListener statusChangeListener; private Cell() { setPadding(new Insets(0, 0, 4, 0)); prefWidthProperty().bind(cellWidth); FXUtils.setLimitHeight(left, STATUS_ICON_SIZE); FXUtils.setLimitWidth(left, STATUS_ICON_SIZE); BorderPane.setAlignment(left, Pos.CENTER_LEFT); BorderPane.setMargin(left, new Insets(0, 12, 0, 0)); BorderPane.setAlignment(title, Pos.CENTER_LEFT); pane.setCenter(title); DoubleBinding barWidth = Bindings.createDoubleBinding(() -> { Insets padding = pane.getPadding(); Insets insets = pane.getInsets(); return pane.getWidth() - padding.getLeft() - padding.getRight() - insets.getLeft() - insets.getRight(); }, pane.paddingProperty(), pane.widthProperty()); bar.minWidthProperty().bind(barWidth); bar.prefWidthProperty().bind(barWidth); bar.maxWidthProperty().bind(barWidth); BorderPane.setMargin(bar, PROGRESS_BAR_MARGIN); setGraphic(pane); } private void updateLeftIcon(StageNode.Status status) { left.getChildren().setAll(status.svg.createIcon(STATUS_ICON_SIZE)); } @Override protected void updateItem(Node item, boolean empty) { super.updateItem(item, empty); pane.paddingProperty().unbind(); title.textProperty().unbind(); message.textProperty().unbind(); bar.setSmoothProgress(false); bar.progressProperty().unbind(); StageNode prevStageNode; if (prevStageNodeRef != null && (prevStageNode = prevStageNodeRef.get()) != null) prevStageNode.status.removeListener(statusChangeListener); if (item instanceof ProgressListNode progressListNode) { title.setText(progressListNode.title); message.textProperty().bind(progressListNode.message); bar.progressProperty().bind(progressListNode.progress); pane.paddingProperty().bind(progressNodePadding); pane.setLeft(null); pane.setRight(message); pane.setBottom(bar); } else if (item instanceof StageNode stageNode) { title.textProperty().bind(stageNode.title); message.setText(""); bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS); pane.setPadding(Insets.EMPTY); pane.setLeft(left); pane.setRight(message); pane.setBottom(null); updateLeftIcon(stageNode.status.get()); if (statusChangeListener == null) statusChangeListener = new StatusChangeListener(this); stageNode.status.addListener(statusChangeListener); prevStageNodeRef = new WeakReference<>(stageNode); } else { // item == null title.setText(""); message.setText(""); bar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS); pane.setPadding(Insets.EMPTY); pane.setLeft(null); pane.setRight(null); pane.setBottom(null); } bar.setSmoothProgress(true); } } private static final class StatusChangeListener implements ChangeListener, WeakListener { private final WeakReference cellRef; private StatusChangeListener(Cell cell) { this.cellRef = new WeakReference<>(cell); } @Override public boolean wasGarbageCollected() { return cellRef.get() == null; } @Override public void changed(ObservableValue observable, StageNode.Status oldValue, StageNode.Status newValue) { Cell cell = cellRef.get(); if (cell == null) { if (observable != null) observable.removeListener(this); return; } cell.updateLeftIcon(newValue); } } private static abstract class Node { } private static final class StageNode extends Node { private int runningTasksCount = 0; private enum Status { WAITING(SVG.MORE_HORIZ), RUNNING(SVG.ARROW_FORWARD), SUCCESS(SVG.CHECK), FAILED(SVG.CLOSE); private final SVG svg; Status(SVG svg) { this.svg = svg; } } private final ObjectProperty status = new SimpleObjectProperty<>(Status.WAITING); private final StringProperty title = new SimpleStringProperty(); private final String message; private int count = 0; private int total = 0; private StageNode(String stage) { String stageKey; String stageValue; int idx = stage.indexOf(':'); if (idx >= 0) { stageKey = stage.substring(0, idx); stageValue = stage.substring(idx + 1); } else { stageKey = stage; stageValue = null; } // CHECKSTYLE:OFF // @formatter:off message = switch (stageKey) { case "hmcl.modpack" -> i18n("install.modpack"); case "hmcl.modpack.download" -> i18n("launch.state.modpack"); case "hmcl.install.assets" -> i18n("assets.download"); case "hmcl.install.libraries" -> i18n("libraries.download"); case "hmcl.install.game" -> i18n("install.installer.install", i18n("install.installer.game") + " " + stageValue); case "hmcl.install.forge" -> i18n("install.installer.install", i18n("install.installer.forge") + " " + stageValue); case "hmcl.install.cleanroom" -> i18n("install.installer.install", i18n("install.installer.cleanroom") + " " + stageValue); case "hmcl.install.neoforge" -> i18n("install.installer.install", i18n("install.installer.neoforge") + " " + stageValue); case "hmcl.install.liteloader" -> i18n("install.installer.install", i18n("install.installer.liteloader") + " " + stageValue); case "hmcl.install.optifine" -> i18n("install.installer.install", i18n("install.installer.optifine") + " " + stageValue); case "hmcl.install.fabric" -> i18n("install.installer.install", i18n("install.installer.fabric") + " " + stageValue); case "hmcl.install.fabric-api" -> i18n("install.installer.install", i18n("install.installer.fabric-api") + " " + stageValue); case "hmcl.install.legacyfabric" -> i18n("install.installer.install", i18n("install.installer.legacyfabric") + " " + stageValue); case "hmcl.install.legacyfabric-api" -> i18n("install.installer.install", i18n("install.installer.legacyfabric-api") + " " + stageValue); case "hmcl.install.quilt" -> i18n("install.installer.install", i18n("install.installer.quilt") + " " + stageValue); case "hmcl.install.quilt-api" -> i18n("install.installer.install", i18n("install.installer.quilt-api") + " " + stageValue); default -> i18n(stageKey); }; // @formatter:on // CHECKSTYLE:ON title.set(message); } private void begin() { runningTasksCount++; if (status.get() == Status.WAITING || status.get() == Status.SUCCESS) { status.set(Status.RUNNING); } } public void succeed() { runningTasksCount = Math.max(0, runningTasksCount - 1); if (runningTasksCount == 0) { status.set(Status.SUCCESS); } } public void fail() { runningTasksCount = Math.max(0, runningTasksCount - 1); status.set(Status.FAILED); } public void count() { updateCounter(++count, total); } public void addTotal(int n) { this.total += n; updateCounter(count, total); } public void updateCounter(int count, int total) { title.setValue(total > 0 ? message + " - " + count + "/" + total : message ); } } private static final class ProgressListNode extends Node { private final String title; private final StringProperty message = new SimpleStringProperty(""); private final DoubleProperty progress = new SimpleDoubleProperty(0.0); private ProgressListNode(Task task) { this.title = task.getName(); progress.bind(task.progressProperty()); } public void unbind() { progress.unbind(); } public void setThrowable(Throwable throwable) { unbind(); message.set(throwable.getLocalizedMessage()); progress.set(0.); } } } ================================================ FILE: HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java ================================================ /* * Hello Minecraft! Launcher * Copyright (C) 2020 huangyuhui and contributors * * 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 . */ package org.jackhuang.hmcl.ui.construct; import javafx.beans.binding.Bindings; import javafx.beans.property.StringProperty; import javafx.beans.property.StringPropertyBase; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Pos; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; public class TwoLineListItem extends VBox { private static final String DEFAULT_STYLE_CLASS = "two-line-list-item"; private final HBox firstLine; private HBox secondLine; private final Label lblTitle; private Label lblSubtitle; public TwoLineListItem() { getStyleClass().add(DEFAULT_STYLE_CLASS); setMouseTransparent(true); lblTitle = new Label(); lblTitle.getStyleClass().add("title"); this.firstLine = new HBox(lblTitle); firstLine.getStyleClass().add("first-line"); firstLine.setAlignment(Pos.CENTER_LEFT); this.getChildren().setAll(firstLine); } public TwoLineListItem(String titleString, String subtitleString) { this(); setTitle(titleString); setSubtitle(subtitleString); } private void initSecondLine() { if (secondLine == null) { lblSubtitle = new Label(); lblSubtitle.getStyleClass().add("subtitle"); secondLine = new HBox(lblSubtitle); } } private final StringProperty title = new StringPropertyBase() { @Override public Object getBean() { return TwoLineListItem.this; } @Override public String getName() { return "title"; } @Override protected void invalidated() { lblTitle.setText(get()); } }; public StringProperty titleProperty() { return title; } public String getTitle() { return title.get(); } public void setTitle(String title) { this.title.set(title); } private StringProperty subtitle; public StringProperty subtitleProperty() { if (subtitle == null) { subtitle = new StringPropertyBase() { @Override public Object getBean() { return TwoLineListItem.this; } @Override public String getName() { return "subtitle"; } @Override protected void invalidated() { String subtitle = get(); if (subtitle != null) { initSecondLine(); lblSubtitle.setText(subtitle); if (getChildren().size() == 1) getChildren().add(secondLine); } else if (secondLine != null) { lblSubtitle.setText(null); if (getChildren().size() > 1) getChildren().setAll(firstLine); } } }; } return subtitle; } public String getSubtitle() { return subtitle != null ? subtitleProperty().get() : null; } public void setSubtitle(String subtitle) { if (this.subtitle == null && subtitle == null) return; subtitleProperty().set(subtitle); } public Label getTitleLabel() { return lblTitle; } public Label getSubtitleLabel() { initSecondLine(); return lblSubtitle; } private ObservableList