Compare commits

...
4 Commits
Author SHA1 Message Date
layue13 f183b4a5dc Merge pull request 'feat: UI refresh batch 1 (home progress, single settings page, slim about)' (#24) from feat/ui-refresh-1 into main
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (push) Successful in 10m12s
2026-11-11 08:31:37 +00:00
layue13andClaude Sonnet 5.5 8eec436e23 Merge origin/main into feat/ui-refresh-1
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Successful in 8m50s
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TiBPbVGBHNHdzqfq1L5tch
2026-11-11 16:22:08 +08:00
layue13andClaude Sonnet 5.5 bcefa87e27 fix: base home sync progress on announced download total
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
Java CI / build (pull_request) Canceled after 1m41s
The percentage used finished/seen task counts, which hit the 99% cap as soon
as the first tasks finished and then stayed there while new tasks were still
being discovered. Use the "total" property announced by the sync task as the
denominator and count finished file downloads; stay indeterminate until known.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TiBPbVGBHNHdzqfq1L5tch
2026-11-11 12:36:56 +08:00
layue13andClaude Sonnet 5.5 26187e6f61 feat: UI refresh batch 1 (home progress card, single settings page, slim about)
Mirror Repository / Mirror to CNB (push) Skipped
Mirror Repository / Mirror to Gitee (push) Skipped
- Home: join/update button turns the bottom card into an in-place progress
  view (percent, thin bar, downloaded size/speed, cancel, four-step indicator,
  error + retry). ClientSyncProgress mirrors the existing task executor.
- Label "Start Game" -> "进入服务器" (en: Join Server) in en/zh_Hans/zh_Hant.
- Settings merged into one page: General / Game / Help cards; Java management
  and About open as sub pages.
- About trimmed; system/device/runtime info moved into "Copy diagnostics".

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TiBPbVGBHNHdzqfq1L5tch
2026-11-11 12:14:47 +08:00
10 changed files with 807 additions and 341 deletions

No files matched your search

@@ -0,0 +1,199 @@
// SPDX-FileCopyrightText: Copyright (C) 2026 The HMCL Authors
// SPDX-License-Identifier: GPL-3.0-or-later WITH AdditionRef-HMCL
package org.jackhuang.hmcl.ui.instances;
import javafx.beans.property.*;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.task.FileDownloadTask;
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.util.TaskManager;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.util.function.Consumer;
/// Observable progress of the home button's "install / update the client, then join" workflow.
///
/// The model is only a view of the task executor started by [Instances#ensureClientAndJoin(ClientSyncProgress)]:
/// it never changes what is downloaded. All properties must be read and written on the JavaFX thread.
/// Overall percentage is estimated from finished file downloads against the total announced by the sync task, and stays indeterminate until that total is known.
@NotNullByDefault
public final class ClientSyncProgress {
/// Lifecycle of the workflow.
public enum State {
/// Nothing is running; the home card shows its normal facts and button.
IDLE,
/// A task is running; the home card shows the progress state.
RUNNING,
/// The last run failed; the home card shows [#errorProperty()] and a retry button.
FAILED
}
/// Index of the step that checks the remote manifest.
public static final int STEP_CHECK = 0;
/// Index of the step that prepares Java.
public static final int STEP_JAVA = 1;
/// Index of the step that synchronises the modpack.
public static final int STEP_SYNC = 2;
/// Index of the final step that joins the server.
public static final int STEP_JOIN = 3;
private final ObjectProperty<State> state = new SimpleObjectProperty<>(this, "state", State.IDLE);
/// The first step that is not yet finished; equals 4 once every step is done.
private final IntegerProperty step = new SimpleIntegerProperty(this, "step", STEP_CHECK);
/// Fraction in `[0, 1]`, or `-1` while unknown.
private final DoubleProperty progress = new SimpleDoubleProperty(this, "progress", -1);
private final StringProperty detail = new SimpleStringProperty(this, "detail", "");
private final StringProperty error = new SimpleStringProperty(this, "error", "");
private @Nullable TaskExecutor executor;
private long downloadedBytes;
/// Total number of downloads announced by tasks so far.
private int expectedFiles;
/// Number of finished file downloads.
private int finishedFiles;
/// Tasks whose `total` has already been added to [#expectedFiles].
private final java.util.Set<Task<?>> announcedTasks = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
private boolean cancelled;
/// Keeps the weakly registered speed listener alive.
@SuppressWarnings({"unused", "FieldCanBeLocal"})
private final Consumer<FetchTask.SpeedEvent> speedHandler;
/// Creates an idle progress model.
public ClientSyncProgress() {
speedHandler = FetchTask.SPEED_EVENT.registerWeak(event -> FXUtils.runInFX(() -> {
if (state.get() != State.RUNNING || event.getSpeed() <= 0)
return;
downloadedBytes += event.getSpeed();
detail.set(String.format("%.0f MB", downloadedBytes / 1048576.0) + " · " + I18n.formatSpeed(event.getSpeed()));
}));
}
/// The workflow state.
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
/// The first unfinished step index (0–3), or 4 when everything is done.
public ReadOnlyIntegerProperty stepProperty() {
return step;
}
/// Overall fraction in `[0, 1]`, or `-1` when it cannot be estimated yet.
public ReadOnlyDoubleProperty progressProperty() {
return progress;
}
/// Transfer details such as downloaded size and speed; empty when unknown.
public ReadOnlyStringProperty detailProperty() {
return detail;
}
/// The failure message of the last run; empty unless the state is [State#FAILED].
public ReadOnlyStringProperty errorProperty() {
return error;
}
/// Starts a new run: resets counters and shows the progress state at the first step.
void begin() {
executor = null;
cancelled = false;
downloadedBytes = 0;
expectedFiles = 0;
finishedFiles = 0;
announcedTasks.clear();
progress.set(-1);
detail.set("");
error.set("");
step.set(STEP_CHECK);
state.set(State.RUNNING);
}
/// Moves the indicator forward to `newStep`; it never moves backwards.
void advanceTo(int newStep) {
if (newStep > step.get())
step.set(newStep);
}
/// Returns to the idle state, for example after the game was launched or the run was cancelled.
void finish() {
executor = null;
state.set(State.IDLE);
}
/// Shows `message` in the card together with the retry button.
void fail(String message) {
executor = null;
error.set(message);
state.set(State.FAILED);
}
/// Dismisses a failure and returns to the idle state.
public void dismiss() {
if (state.get() == State.FAILED)
state.set(State.IDLE);
}
/// Cancels the running task, if any, and returns to the idle state.
public void cancel() {
TaskExecutor current = executor;
cancelled = true;
if (current != null)
current.cancel();
finish();
}
/// Returns whether the last run was cancelled by the user.
boolean isCancelled() {
return cancelled;
}
/// Recomputes the fraction from finished downloads; stays indeterminate until a total is known.
private void refreshProgress(TaskExecutor current) {
if (executor != current || expectedFiles <= 0)
return;
double value = Math.min(0.99, (double) finishedFiles / expectedFiles);
if (value > progress.get())
progress.set(value);
}
/// Submits `task` to the serial task queue and mirrors its task count into the progress.
///
/// @param task the task to run
void submit(Task<?> task) {
TaskExecutor taskExecutor = task.executor();
executor = taskExecutor;
taskExecutor.addTaskListener(new TaskListener() {
/// Tasks announce how many downloads they will start through the `total` property
/// (set once the file list is known), so the denominator never grows with discovered tasks.
@Override
public void onPropertiesUpdate(Task<?> updated) {
Object total = updated.getProperties().get("total");
if (total instanceof Integer count && count > 0) {
FXUtils.runInFX(() -> {
if (announcedTasks.add(updated))
expectedFiles += count;
refreshProgress(taskExecutor);
});
}
}
@Override
public void onFinished(Task<?> finishedTask) {
if (finishedTask instanceof FileDownloadTask) {
FXUtils.runInFX(() -> {
finishedFiles++;
refreshProgress(taskExecutor);
});
}
}
});
TaskManager.getInstance().submit(taskExecutor, null);
}
}
@@ -100,6 +100,14 @@ public final class Instances {
/// Prefer this for the home Start / Join button. Existing server-modpack instances get differential
/// updates during launch via [org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask].
public static void ensureClientAndJoin() {
ensureClientAndJoin(null);
}
/// Same as [#ensureClientAndJoin()], but reports task progress to `progress` instead of opening a
/// task dialog, so the home card can show it in place. Errors are shown through `progress` too.
///
/// @param progress the progress model to drive, or `null` to use the task dialog
public static void ensureClientAndJoin(@Nullable ClientSyncProgress progress) {
HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository();
if (!repository.isLoaded()) {
return;
@@ -113,33 +121,68 @@ public final class Instances {
Path bundled = Metadata.findBundledModpackFile();
if (bundled != null) {
installBundledThenJoin(repository, bundled);
installBundledThenJoin(repository, bundled, progress);
return;
}
Controllers.taskDialog(
new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
.thenComposeAsync(Schedulers.javafx(), result -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
result, ServerModpackManifest.class);
if (manifest == null) {
throw new IOException(i18n("modpack.type.server.malformed"));
}
return ensureRemoteClientInstalled(repository, manifest);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install or update TDLM client", exception);
Controllers.dialog(
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
return;
}
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
}),
i18n("modpack.installing"),
TaskCancellationAction.NORMAL);
if (progress != null) {
progress.begin();
}
Task<?> task = new GetTask(WebURL.parse(Metadata.getClientManifestUrl()))
.thenComposeAsync(Schedulers.javafx(), result -> {
ServerModpackManifest manifest = JsonUtils.fromMaybeMalformedJson(
result, ServerModpackManifest.class);
if (manifest == null) {
throw new IOException(i18n("modpack.type.server.malformed"));
}
if (progress != null) {
// Java is prepared by the launch step; mark it as passed once the manifest is known.
progress.advanceTo(ClientSyncProgress.STEP_SYNC);
}
return ensureRemoteClientInstalled(repository, manifest);
})
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install or update TDLM client", exception);
reportFailure(progress, exception);
return;
}
if (progress != null) {
progress.advanceTo(ClientSyncProgress.STEP_JOIN);
}
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
if (progress != null) {
progress.advanceTo(ClientSyncProgress.STEP_JOIN + 1);
progress.finish();
}
});
runSyncTask(task, progress, TaskCancellationAction.NORMAL);
}
/// Runs `task` in the progress card when `progress` is set, otherwise in the task dialog.
private static void runSyncTask(Task<?> task, @Nullable ClientSyncProgress progress, TaskCancellationAction cancellation) {
if (progress != null) {
progress.submit(task);
} else {
Controllers.taskDialog(task, i18n("modpack.installing"), cancellation);
}
}
/// Shows a failure in the progress card, or in an error dialog when there is no card.
/// A user-requested cancellation is not reported as an error.
private static void reportFailure(@Nullable ClientSyncProgress progress, Throwable exception) {
if (progress != null) {
if (progress.isCancelled()) {
progress.finish();
} else {
progress.fail(StringUtils.getStackTrace(exception));
}
return;
}
Controllers.dialog(
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
}
/// Silent first-install / update from the org client manifest — no wizard UI.
@@ -191,9 +234,12 @@ public final class Instances {
TaskCancellationAction.NORMAL);
}
private static void installBundledThenJoin(HMCLGameRepository repository, Path bundled) {
Controllers.taskDialog(
Task.composeAsync(Schedulers.io(), () -> {
private static void installBundledThenJoin(HMCLGameRepository repository, Path bundled, @Nullable ClientSyncProgress progress) {
if (progress != null) {
progress.begin();
progress.advanceTo(ClientSyncProgress.STEP_SYNC);
}
Task<?> task = Task.composeAsync(Schedulers.io(), () -> {
Modpack modpack = ModpackHelper.readModpackManifest(bundled);
String name = LocalModpackPage.processModpackName(modpack.getName());
if (StringUtils.isBlank(name)) {
@@ -213,16 +259,16 @@ public final class Instances {
.whenComplete(Schedulers.javafx(), (ignored, exception) -> {
if (exception != null) {
LOG.warning("Failed to install bundled modpack", exception);
Controllers.dialog(
StringUtils.getStackTrace(exception),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
reportFailure(progress, exception);
return;
}
joinServer(GameDirectoryManager.getSelectedRepository().getSelectedInstance());
}),
i18n("modpack.installing"),
TaskCancellationAction.NO_CANCEL);
if (progress != null) {
progress.advanceTo(ClientSyncProgress.STEP_JOIN + 1);
progress.finish();
}
});
runSyncTask(task, progress, TaskCancellationAction.NO_CANCEL);
}
/// Installs when missing; selects an existing instance otherwise.
@@ -3,11 +3,18 @@
package org.jackhuang.hmcl.ui.main;
import com.google.gson.*;
import com.jfoenix.controls.JFXButton;
import javafx.geometry.Insets;
import javafx.scene.control.ScrollPane;
import javafx.scene.image.Image;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.setting.GameDirectoryManager;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
@@ -20,7 +27,6 @@ import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jackhuang.hmcl.util.platform.hardware.CentralProcessor;
import org.jackhuang.hmcl.util.platform.hardware.DeviceInfo;
import org.jackhuang.hmcl.util.platform.hardware.GraphicsCard;
import org.jackhuang.hmcl.util.platform.hardware.PageFileConfiguration;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
@@ -34,16 +40,22 @@ import static org.jackhuang.hmcl.util.i18n.I18n.getTranslator;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Displays launcher, system, device, runtime, and project information.
/// Displays a compact about page: identity, update check, licenses, source link and diagnostics.
///
/// System, device and runtime details are not listed; they are collected by "Copy diagnostics".
@NotNullByDefault
public final class AboutPage extends SpinnerPane {
/// The upstream project that this launcher is based on, and the source of its GPL-licensed code.
private static final String SOURCE_URL = "https://github.com/HMCL-dev/HMCL";
/// Keeps theme listeners alive while the page is visible.
private final WeakListenerHolder holder = new WeakListenerHolder();
/// Builds the page from cached hardware information and bundled project lists.
/// Builds the page from bundled project lists.
public AboutPage() {
VBox content = new VBox();
VBox content = new VBox(13);
content.setPadding(new Insets(21));
content.getStyleClass().add("spinner-pane-content");
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
@@ -56,196 +68,112 @@ public final class AboutPage extends SpinnerPane {
launcher.setLargeTitle(true);
launcher.setLeading(FXUtils.newBuiltinImage("/assets/img/icon.png"));
launcher.setTitle(Metadata.FULL_NAME);
launcher.setSubtitle(Metadata.VERSION);
launcher.setSubtitle(Metadata.VERSION + " · " + i18n("about.based_on"));
JFXButton checkUpdate = FXUtils.newBorderButton(i18n("about.check_update"));
checkUpdate.setOnAction(e -> checkClientUpdate(checkUpdate));
launcher.setRight(checkUpdate);
about.getContent().setAll(launcher);
}
ComponentList systemInfo = new ComponentList();
{
var system = new LinePane();
system.setLargeTitle(true);
system.setLeading(SVG.LUCIDE_APP_WINDOW.createIcon(32));
system.setTitle(i18n("about.system.os"));
system.setSubtitle((OperatingSystem.OS_RELEASE_PRETTY_NAME == null
? "%s %s".formatted(OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())
: "%s (%s %s)".formatted(OperatingSystem.OS_RELEASE_PRETTY_NAME, OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())));
systemInfo.getContent().add(system);
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && SystemInfo.getDesktopEnvironment() != null) {
var desktop = new LinePane();
desktop.setLargeTitle(true);
desktop.setLeading(SVG.LUCIDE_PANELS_TOP_LEFT.createIcon(32));
desktop.setTitle(i18n("about.system.desktop"));
desktop.setSubtitle(SystemInfo.getDesktopEnvironment());
systemInfo.getContent().add(desktop);
}
}
ComponentList deviceInfo = new ComponentList();
{
@Nullable DeviceInfo deviceIdentity = SystemInfo.getDeviceInfo();
if (deviceIdentity != null
&& !"Virtual Machine".equals(deviceIdentity.name())
&& OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
&& (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS || !Architecture.SYSTEM_ARCH.isX86())) {
var device = new LinePane();
device.setLargeTitle(true);
device.setLeading(SVG.HOST.createIcon(32));
device.setTitle(i18n("about.device.model"));
device.setSubtitle(getTranslator().formatDeviceName(deviceIdentity));
deviceInfo.getContent().add(device);
}
@Unmodifiable List<CentralProcessor> processors = SystemInfo.getCentralProcessors();
for (int i = 0; i < processors.size(); i++) {
String title = processors.size() == 1
? i18n("about.device.cpu")
: i18n("about.device.cpu.n", i + 1);
CentralProcessor processor = processors.get(i);
String name = processor.getName();
@Nullable CentralProcessor.Cores cores = processor.getCores();
String subtitle;
if (cores == null)
subtitle = i18n("about.device.cpu.name", name);
else if (cores.efficiency() > 0) {
subtitle = cores.logical() == cores.physical()
? i18n("about.device.cpu.name.cores.hybrid", name, cores.performance(), cores.efficiency())
: i18n("about.device.cpu.name.cores.hybrid.threads", name, cores.performance(), cores.efficiency(), cores.logical());
} else {
subtitle = cores.logical() == cores.physical()
? i18n("about.device.cpu.name.cores", name, cores.physical())
: i18n("about.device.cpu.name.cores.threads", name, cores.physical(), cores.logical());
}
var pane = new LinePane();
pane.setLargeTitle(true);
pane.setTitle(title);
pane.setSubtitle(subtitle);
pane.setLeading(SVG.LUCIDE_CPU.createIcon(32));
pane.getTags().add(new Tag(Architecture.SYSTEM_ARCH.getDisplayName()));
deviceInfo.getContent().add(pane);
}
@Unmodifiable List<GraphicsCard> visibleGraphicsCards = SystemInfo.getGraphicsCards().stream()
.filter(card -> card.getType() != GraphicsCard.Type.DISPLAY_ONLY && !"Unknown".equals(card.getName()))
.sorted(Comparator.comparing(GraphicsCard::getType))
.toList();
for (int i = 0; i < visibleGraphicsCards.size(); i++) {
GraphicsCard graphicsCard = visibleGraphicsCards.get(i);
String title = visibleGraphicsCards.size() == 1
? i18n("about.device.gpu")
: i18n("about.device.gpu.n", i + 1);
var button = new LinePane();
button.setLargeTitle(true);
button.setLeading(SVG.LUCIDE_GPU.createIcon(32));
button.setTitle(title);
StringBuilder subtitle = new StringBuilder(graphicsCard.getName());
if (graphicsCard.getDriver() != null || graphicsCard.getDriverVersion() != null) {
subtitle.append(" (");
if (graphicsCard.getDriver() != null) {
subtitle.append(graphicsCard.getDriver());
if (graphicsCard.getDriverVersion() != null) {
subtitle.append(' ');
}
}
if (graphicsCard.getDriverVersion() != null) {
subtitle.append(graphicsCard.getDriverVersion());
}
subtitle.append(')');
}
button.setSubtitle(subtitle.toString());
switch (graphicsCard.getType()) {
case INTEGRATED -> button.getTags().add(new Tag(i18n("about.device.gpu.type.integrated")));
case DISCRETE -> button.getTags().add(new Tag(i18n("about.device.gpu.type.discrete")));
case VIRTUAL -> button.getTags().add(new Tag(i18n("about.device.gpu.type.virtual")));
}
deviceInfo.getContent().add(button);
}
if (SystemInfo.getTotalMemorySize() > 0) {
String subtitle = i18n("about.device.memory.system",
"%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())));
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
long pageFileSize = SystemInfo.getTotalPageFileSize();
if (pageFileSize < 0) {
subtitle = i18n("about.device.memory.page_files.unknown", subtitle);
} else {
String pageFileCapacity = "%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(pageFileSize));
PageFileConfiguration configuration = SystemInfo.getPageFileConfiguration();
subtitle = switch (configuration.mode()) {
case MANUAL -> i18n("about.device.memory.page_files.maximum",
subtitle,
pageFileCapacity,
"%.1f GiB".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(configuration.maximumSize())));
case SYSTEM_MANAGED, MIXED, UNKNOWN -> i18n("about.device.memory.page_files", subtitle, pageFileCapacity);
};
}
}
var memory = new LinePane();
memory.setLargeTitle(true);
memory.setLeading(SVG.LUCIDE_MEMORY_STICK.createIcon(32));
memory.setTitle(i18n("about.device.memory"));
memory.setSubtitle(subtitle);
deviceInfo.getContent().add(memory);
}
}
ComponentList runtimeInfo = new ComponentList();
{
var java = new LinePane();
java.setLargeTitle(true);
java.setLeading(SVG.LOCAL_CAFE.createIcon(32));
java.setTitle("Java");
java.setSubtitle("%s, %s, %s".formatted(
Runtime.version().toString(),
System.getProperty("java.vm.name"),
System.getProperty("java.vendor")));
runtimeInfo.getContent().add(java);
var javaFX = new LinePane();
javaFX.setLargeTitle(true);
javaFX.setLeading(SVG.LOCAL_CAFE.createIcon(32));
javaFX.setTitle("JavaFX");
javaFX.setSubtitle(System.getProperty("javafx.runtime.version"));
runtimeInfo.getContent().add(javaFX);
}
ComponentList deps = loadIconedTwoLineList("/assets/about/deps.json");
// The HMCL GPLv3 notice and the third-party dependency list must stay reachable.
ComponentList legal = new ComponentList();
{
var copyright = new LinePane();
copyright.setLargeTitle(true);
copyright.setTitle(i18n("about.copyright"));
copyright.setSubtitle(i18n("about.copyright.statement"));
var license = new LinePane();
license.setLargeTitle(true);
license.setTitle(i18n("about.open_source"));
license.setSubtitle(i18n("about.open_source.statement") + "\n" + i18n("about.copyright.statement"));
legal.getContent().add(license);
legal.getContent().setAll(copyright);
var source = new LineButton();
source.setLargeTitle(true);
source.setTitle(i18n("about.source_code"));
source.setSubtitle(SOURCE_URL);
source.setTrailingIcon(SVG.OPEN_IN_NEW);
source.setOnAction(e -> FXUtils.openLink(SOURCE_URL));
legal.getContent().add(source);
ComponentList deps = loadIconedTwoLineList("/assets/about/deps.json");
var depsSublist = new ComponentSublist(() -> List.of(deps));
depsSublist.setTitle(i18n("about.dependency"));
legal.getContent().add(depsSublist);
}
content.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("about")),
about,
ComponentList.createComponentListTitle(i18n("about.system")),
systemInfo,
ComponentList.createComponentListTitle(i18n("about.device")),
deviceInfo,
ComponentList.createComponentListTitle(i18n("about.runtime")),
runtimeInfo,
ComponentList.createComponentListTitle(i18n("about.dependency")),
deps,
ComponentList.createComponentListTitle(i18n("about.legal")),
legal
);
ComponentList support = new ComponentList();
{
var diagnostics = new LineButton();
diagnostics.setLargeTitle(true);
diagnostics.setTitle(i18n("about.copy_diagnostics"));
diagnostics.setTrailingIcon(SVG.CONTENT_COPY);
diagnostics.setOnAction(e -> FXUtils.copyText(buildDiagnostics()));
support.getContent().add(diagnostics);
}
content.getChildren().setAll(about, legal, support);
}
/// Checks whether the installed client pack is behind the remote manifest and tells the user.
///
/// @param button the button to disable while the check runs
private static void checkClientUpdate(JFXButton button) {
HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance();
if (instance == null) {
Controllers.showToast(i18n("about.check_update.not_installed"));
return;
}
button.setDisable(true);
Schedulers.io().execute(() -> {
boolean available = Instances.isClientUpdateAvailable(instance);
FXUtils.runInFX(() -> {
button.setDisable(false);
Controllers.showToast(i18n(available ? "about.check_update.available" : "about.check_update.latest"));
});
});
}
/// Collects launcher, system, device and runtime information as plain text for bug reports.
private static String buildDiagnostics() {
StringBuilder sb = new StringBuilder();
sb.append(Metadata.FULL_NAME).append(' ').append(Metadata.VERSION).append('\n');
sb.append("OS: ").append(OperatingSystem.OS_RELEASE_PRETTY_NAME == null
? "%s %s".formatted(OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion())
: "%s (%s %s)".formatted(OperatingSystem.OS_RELEASE_PRETTY_NAME, OperatingSystem.SYSTEM_NAME, OperatingSystem.SYSTEM_VERSION.getVersion()))
.append(" (").append(Architecture.SYSTEM_ARCH.getDisplayName()).append(")\n");
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && SystemInfo.getDesktopEnvironment() != null)
sb.append("Desktop: ").append(SystemInfo.getDesktopEnvironment()).append('\n');
@Nullable DeviceInfo deviceIdentity = SystemInfo.getDeviceInfo();
if (deviceIdentity != null && !"Virtual Machine".equals(deviceIdentity.name()))
sb.append("Device: ").append(getTranslator().formatDeviceName(deviceIdentity)).append('\n');
for (CentralProcessor processor : SystemInfo.getCentralProcessors())
sb.append("CPU: ").append(processor.getName()).append('\n');
@Unmodifiable List<GraphicsCard> cards = SystemInfo.getGraphicsCards().stream()
.filter(card -> card.getType() != GraphicsCard.Type.DISPLAY_ONLY && !"Unknown".equals(card.getName()))
.sorted(Comparator.comparing(GraphicsCard::getType))
.toList();
for (GraphicsCard card : cards) {
sb.append("GPU: ").append(card.getName());
if (card.getDriver() != null || card.getDriverVersion() != null) {
sb.append(" (");
if (card.getDriver() != null)
sb.append(card.getDriver());
if (card.getDriverVersion() != null)
sb.append(card.getDriver() != null ? " " : "").append(card.getDriverVersion());
sb.append(')');
}
sb.append('\n');
}
if (SystemInfo.getTotalMemorySize() > 0)
sb.append("Memory: %.1f GiB%n".formatted(DataSizeUnit.GIGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize())));
sb.append("Java: %s, %s, %s%n".formatted(
Runtime.version(), System.getProperty("java.vm.name"), System.getProperty("java.vendor")));
sb.append("JavaFX: ").append(System.getProperty("javafx.runtime.version")).append('\n');
return sb.toString();
}
private static Image loadImage(String url) {
@@ -4,60 +4,21 @@ package org.jackhuang.hmcl.ui.main;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jetbrains.annotations.NotNullByDefault;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class LauncherSettingsPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
/// The launcher settings destination: a single [SettingsPage] without sidebar tabs.
@NotNullByDefault
public class LauncherSettingsPage extends StackPane implements DecoratorPage {
/// The page state holding the "Settings" title.
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("settings")));
/// Manages tab selection and content transitions independently of the sidebar.
private final TabController tabController;
/// The tab whose content manages installed Java runtimes.
private final TabItem<JavaManagementPage> javaManagementTab;
/// The tab for general launcher settings.
private final TabItem<SettingsPage> settingsTab;
/// The tab for download settings.
private final TabItem<DownloadSettingsPage> downloadTab;
/// The tab displaying launcher information.
private final TabItem<AboutPage> aboutTab;
private final TransitionPane transitionPane = new TransitionPane();
/// Creates the settings tabs and fills the shared navigation sidebar.
/// Creates the page with the single settings view.
public LauncherSettingsPage() {
javaManagementTab = new TabItem<>(JavaManagementPage::new);
settingsTab = new TabItem<>(SettingsPage::new);
downloadTab = new TabItem<>(DownloadSettingsPage::new);
aboutTab = new TabItem<>(AboutPage::new);
tabController = new TabController(transitionPane,
javaManagementTab,
settingsTab,
downloadTab,
aboutTab);
tabController.selectTab(settingsTab);
getSidebar()
.addNavigationDrawerTab(tabController, javaManagementTab, i18n("java.management"), SVG.LOCAL_CAFE, SVG.LOCAL_CAFE_FILL)
.addNavigationDrawerTab(tabController, settingsTab, i18n("settings.launcher.general"), SVG.TUNE)
.addNavigationDrawerTab(tabController, downloadTab, i18n("download"), SVG.DOWNLOAD)
.addNavigationDrawerTab(tabController, aboutTab, i18n("about"), SVG.INFO, SVG.INFO_FILL);
setContent(transitionPane);
}
@Override
public void onPageShown() {
tabController.onPageShown();
}
@Override
public void onPageHidden() {
tabController.onPageHidden();
getChildren().setAll(new SettingsPage());
}
@Override
@@ -6,6 +6,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.property.*;
import javafx.beans.value.ObservableValue;
import javafx.css.PseudoClass;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
@@ -13,6 +14,7 @@ import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
@@ -26,6 +28,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.IconedMenuItem;
import org.jackhuang.hmcl.ui.construct.PopupMenu;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.instances.ClientSyncProgress;
import org.jackhuang.hmcl.ui.instances.Instances;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.ServerAddress;
@@ -61,6 +64,9 @@ public final class MainPage extends StackPane implements DecoratorPage {
/// Bumped when the home join button starts a new remote-update check, so stale results are ignored.
private int updateCheckGeneration;
/// Progress of the install / update workflow, shown in place of the home card while it runs.
private final ClientSyncProgress syncProgress = new ClientSyncProgress();
{
HBox titleNode = new HBox(8);
titleNode.setPadding(new Insets(0, 0, 0, 2));
@@ -195,7 +201,103 @@ public final class MainPage extends StackPane implements DecoratorPage {
}
});
getChildren().addAll(art, hero, bottom);
// While the client is installed or updated the card turns into a progress view in place.
VBox progressCard = createProgressCard();
StackPane.setAlignment(progressCard, Pos.BOTTOM_CENTER);
progressCard.setMaxHeight(Region.USE_PREF_SIZE);
progressCard.visibleProperty().bind(syncProgress.stateProperty().isNotEqualTo(ClientSyncProgress.State.IDLE));
progressCard.managedProperty().bind(progressCard.visibleProperty());
// The whole bottom block (announcement + card) gives way to the progress card.
bottom.visibleProperty().bind(progressCard.visibleProperty().not());
bottom.managedProperty().bind(bottom.visibleProperty());
getChildren().addAll(art, hero, bottom, progressCard);
}
/// Builds the progress state of the home card: title with percentage, a thin progress bar,
/// transfer details, a cancel / retry button and the four-step indicator.
private VBox createProgressCard() {
Label title = new Label();
title.getStyleClass().add("home-progress-title");
ProgressBar bar = new ProgressBar();
bar.getStyleClass().add("home-progress-bar");
bar.setMaxWidth(Double.MAX_VALUE);
bar.progressProperty().bind(syncProgress.progressProperty());
Label detail = new Label();
detail.getStyleClass().add("home-fact-label");
detail.textProperty().bind(syncProgress.detailProperty());
Label error = new Label();
error.getStyleClass().add("home-progress-error");
error.setWrapText(true);
error.setMaxHeight(55);
error.textProperty().bind(syncProgress.errorProperty());
VBox status = new VBox(8, title, bar, detail, error);
status.setAlignment(Pos.CENTER_LEFT);
HBox.setHgrow(status, Priority.ALWAYS);
JFXButton action = new JFXButton();
action.getStyleClass().add("home-progress-button");
action.setOnAction(e -> {
if (syncProgress.stateProperty().get() == ClientSyncProgress.State.FAILED) {
Instances.ensureClientAndJoin(syncProgress);
} else {
syncProgress.cancel();
}
});
// A failed run can also be dismissed to get back to the normal card.
JFXButton dismiss = new JFXButton(i18n("button.cancel"));
dismiss.getStyleClass().add("home-progress-button");
dismiss.setOnAction(e -> syncProgress.dismiss());
dismiss.visibleProperty().bind(syncProgress.stateProperty().isEqualTo(ClientSyncProgress.State.FAILED));
dismiss.managedProperty().bind(dismiss.visibleProperty());
HBox top = new HBox(21, status, new HBox(8, dismiss, action));
top.setAlignment(Pos.CENTER_LEFT);
String[] stepKeys = {"home.progress.step.check", "home.progress.step.java", "home.progress.step.sync", "home.progress.step.join"};
HBox steps = new HBox(21);
Label[] stepLabels = new Label[stepKeys.length];
for (int i = 0; i < stepKeys.length; i++) {
stepLabels[i] = new Label();
stepLabels[i].getStyleClass().add("home-progress-step");
steps.getChildren().add(stepLabels[i]);
}
Runnable refresh = () -> {
ClientSyncProgress.State state = syncProgress.stateProperty().get();
boolean failed = state == ClientSyncProgress.State.FAILED;
int current = syncProgress.stepProperty().get();
for (int i = 0; i < stepKeys.length; i++) {
boolean done = i < current;
boolean active = i == current && !failed;
stepLabels[i].setText((done ? "✓ " : active ? "● " : "○ ") + i18n(stepKeys[i]));
stepLabels[i].pseudoClassStateChanged(PseudoClass.getPseudoClass("done"), done);
stepLabels[i].pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), active);
}
int percent = (int) Math.round(Math.max(0, syncProgress.progressProperty().get()) * 100);
if (failed) {
title.setText(i18n("home.progress.failed"));
action.setText(i18n("home.progress.retry"));
} else {
title.setText(current <= ClientSyncProgress.STEP_JAVA
? i18n("home.progress.checking")
: i18n("home.progress.syncing", percent));
action.setText(i18n("button.cancel"));
}
error.setVisible(failed);
error.setManaged(failed);
detail.setVisible(!failed);
detail.setManaged(!failed);
};
syncProgress.stateProperty().addListener(o -> refresh.run());
syncProgress.stepProperty().addListener(o -> refresh.run());
syncProgress.progressProperty().addListener(o -> refresh.run());
refresh.run();
VBox box = new VBox(21, top, steps);
box.getStyleClass().add("home-card");
return box;
}
/// Sets the home button to install / update / join based on the selected instance and remote manifest.
@@ -265,7 +367,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
/// Installs or updates the client from fileApi when needed, then launches into the server.
private void launch() {
Instances.ensureClientAndJoin();
Instances.ensureClientAndJoin(syncProgress);
}
@Override
@@ -3,7 +3,13 @@
package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXSlider;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.layout.StackPane;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
@@ -11,6 +17,14 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.DownloadSource;
import org.jackhuang.hmcl.setting.GameSettings;
import org.jackhuang.hmcl.setting.SettingsManager;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.DataSizeUnit;
import org.jackhuang.hmcl.util.platform.SystemInfo;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -33,6 +47,7 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.zip.GZIPInputStream;
@@ -43,90 +58,177 @@ import static org.jackhuang.hmcl.setting.SettingsManager.settings;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// The single launcher settings page: general, game and help cards.
///
/// Java management and the about page are reachable from the game and help cards and open as sub pages.
public final class SettingsPage extends ScrollPane {
/// Smallest selectable maximum game memory in MiB.
private static final int MIN_MEMORY_MIB = 1024;
/// Step of the memory slider in MiB.
private static final int MEMORY_STEP_MIB = 256;
/// Builds the three setting cards.
public SettingsPage() {
this.setFitToWidth(true);
VBox rootPane = new VBox(10);
rootPane.setPadding(new Insets(10));
VBox rootPane = new VBox(13);
rootPane.setPadding(new Insets(21));
this.setContent(rootPane);
FXUtils.smoothScrolling(this);
{
ComponentList languagePaneList = new ComponentList();
rootPane.getChildren().addAll(
ComponentList.createComponentListTitle(i18n("settings.launcher.general")), createGeneralCard(),
ComponentList.createComponentListTitle(i18n("settings.group.game")), createGameCard(),
ComponentList.createComponentListTitle(i18n("settings.group.help")), createHelpCard());
}
{
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
/// Creates the card with the language and download source choices.
private static ComponentList createGeneralCard() {
ComponentList list = new ComponentList();
SupportedLocale currentLocale = I18n.getLocale();
chooseLanguagePane.setNullSafeConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty());
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
languagePaneList.getContent().add(chooseLanguagePane);
SupportedLocale currentLocale = I18n.getLocale();
chooseLanguagePane.setNullSafeConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(settings().languageProperty());
list.getContent().add(chooseLanguagePane);
}
// One choice drives both the file download source and the version list source.
var downloadSourcePane = new LineSelectButton<DownloadSource>();
downloadSourcePane.setTitle(i18n("settings.launcher.download_source"));
downloadSourcePane.setNullSafeConverter(source -> i18n("settings.download_source." + source.name().toLowerCase(Locale.ROOT)));
downloadSourcePane.setItems(List.of(DownloadSource.values()));
downloadSourcePane.valueProperty().bindBidirectional(settings().fileDownloadSourceProperty());
downloadSourcePane.valueProperty().addListener((observable, oldValue, newValue) -> {
if (newValue != null)
settings().versionListSourceProperty().set(newValue);
});
list.getContent().add(downloadSourcePane);
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.language")), languagePaneList);
return list;
}
/// Creates the card with the maximum memory slider and the Java summary row.
private static ComponentList createGameCard() {
ComponentList list = new ComponentList();
GameSettings.Preset preset = SettingsManager.getDefaultGameSettingsPresetOrCreate();
int totalMiB = Math.max(MIN_MEMORY_MIB, (int) DataSizeUnit.MEGABYTES.convertFromBytes(SystemInfo.getTotalMemorySize()));
JFXSlider slider = new JFXSlider(MIN_MEMORY_MIB, totalMiB, MIN_MEMORY_MIB);
slider.setBlockIncrement(MEMORY_STEP_MIB);
slider.setPrefWidth(280);
slider.setValueFactory(s -> Bindings.createStringBinding(
() -> "%.1f GB".formatted(s.getValue() / 1024), s.valueProperty()));
@Nullable Integer current = preset.maxMemoryProperty().getValue();
slider.setValue(current != null && current > 0 ? Math.min(current, totalMiB) : Math.min(4096, totalMiB));
// Moving the slider switches off automatic allocation and stores the chosen limit.
slider.valueProperty().addListener((observable, oldValue, newValue) -> {
int mib = (int) (Math.round(newValue.doubleValue() / MEMORY_STEP_MIB) * MEMORY_STEP_MIB);
preset.autoMemoryProperty().setValue(false);
preset.maxMemoryProperty().setValue(Math.max(MIN_MEMORY_MIB, mib));
});
BorderPane memoryPane = new BorderPane();
Label memoryLabel = new Label(i18n("settings.memory"));
BorderPane.setAlignment(memoryLabel, Pos.CENTER_LEFT);
memoryPane.setLeft(memoryLabel);
memoryPane.setRight(slider);
list.getContent().add(memoryPane);
BorderPane javaPane = new BorderPane();
Label javaLabel = new Label(i18n("settings.java.summary"));
BorderPane.setAlignment(javaLabel, Pos.CENTER_LEFT);
javaPane.setLeft(javaLabel);
JFXButton advanced = FXUtils.newBorderButton(i18n("settings.java.advanced"));
advanced.setOnAction(e -> Controllers.navigate(new SubPage(i18n("java.management"), new JavaManagementPage())));
javaPane.setRight(advanced);
list.getContent().add(javaPane);
return list;
}
/// Creates the card with the log actions and the entry to the about page.
private ComponentList createHelpCard() {
ComponentList list = new ComponentList();
BorderPane debugPane = new BorderPane();
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
openLogFolderButton.setOnAction(e -> openLogFolder());
openLogFolderButton.getStyleClass().add("jfx-button-border");
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
SpinnerPane exportLogPane = new SpinnerPane();
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
list.getContent().add(debugPane);
var about = new LineButton();
about.setTitle(i18n("settings.help.about"));
about.setTrailingIcon(SVG.ARROW_FORWARD);
about.setOnAction(e -> Controllers.navigate(new SubPage(i18n("about"), new AboutPage())));
list.getContent().add(about);
return list;
}
/// A titled page that hosts a settings sub page (Java management, about) inside the main navigator.
private static final class SubPage extends StackPane implements DecoratorPage {
/// The page state, holding the title shown in the window header.
private final ReadOnlyObjectWrapper<State> state;
/// Creates the page.
///
/// @param title the title shown in the header
/// @param content the page content
SubPage(String title, Node content) {
this.state = new ReadOnlyObjectWrapper<>(State.fromTitle(title));
getChildren().setAll(content);
}
{
ComponentList miscPaneList = new ComponentList();
{
BorderPane debugPane = new BorderPane();
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
openLogFolderButton.setOnAction(e -> openLogFolder());
openLogFolderButton.getStyleClass().add("jfx-button-border");
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
SpinnerPane exportLogPane = new SpinnerPane();
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
miscPaneList.getContent().add(debugPane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList);
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state.getReadOnlyProperty();
}
}
@@ -2237,6 +2237,65 @@
-fx-text-fill: -monet-on-primary;
}
.home-progress-title {
-fx-font-family: "Noto Sans SC";
-fx-font-size: 16px;
-fx-text-fill: -monet-on-surface;
}
.home-progress-bar {
-fx-pref-height: 8px;
-fx-min-height: 8px;
-fx-max-height: 8px;
}
.home-progress-bar > .track {
-fx-background-color: -monet-outline-variant;
-fx-background-radius: 8px;
-fx-background-insets: 0;
}
.home-progress-bar > .bar {
-fx-background-color: -monet-primary;
-fx-background-radius: 8px;
-fx-background-insets: 0;
-fx-padding: 0;
}
.home-progress-error {
-fx-font-size: 12px;
-fx-text-fill: -monet-error;
}
.home-progress-button {
-fx-font-family: "Noto Sans SC";
-fx-background-color: transparent;
-fx-border-color: -monet-primary;
-fx-border-radius: 13px;
-fx-background-radius: 13px;
-fx-text-fill: -monet-primary;
-fx-padding: 8px 21px;
-fx-cursor: hand;
}
.home-progress-button .label {
-fx-text-fill: -monet-primary;
}
.home-progress-step {
-fx-font-family: "Noto Sans SC";
-fx-font-size: 12px;
-fx-text-fill: -monet-on-surface-variant;
}
.home-progress-step:active {
-fx-text-fill: -monet-primary;
}
.home-progress-step:done {
-fx-text-fill: -monet-on-surface;
}
.home-art-line {
-fx-stroke: -monet-outline-variant;
-fx-stroke-width: 1.5px;
@@ -808,6 +808,26 @@ first_run.microsoft=Sign in with Microsoft
first_run.invalid=Use 1 to 16 letters, digits or underscores.
home.server=Server
home.server.unreachable=Unreachable
home.progress.step.check=Check updates
home.progress.step.java=Prepare Java
home.progress.step.sync=Sync modpack
home.progress.step.join=Join server
home.progress.checking=Checking for updates…
home.progress.syncing=Syncing modpack · %d%%
home.progress.failed=Sync failed
home.progress.retry=Retry
settings.group.game=Game
settings.group.help=Help
settings.java.summary=Java · managed automatically
settings.java.advanced=Advanced…
settings.help.about=About
about.based_on=Based on HMCL
about.check_update=Check for updates
about.check_update.latest=The modpack is up to date.
about.check_update.available=An update is available. Press "Update" on the home page.
about.check_update.not_installed=The modpack is not installed yet.
about.source_code=Source code
about.copy_diagnostics=Copy diagnostics
home.motd=Server announcement
home.restart.title=Restart game
home.restart.update=The game closed to apply an update. Update the client and start it again now?
@@ -815,7 +835,7 @@ home.restart.generic=The game asked to be restarted. Update the client and start
server.join=Join Server
server.update=Update
instance.launch_and_enter_world=Play World
instance.launch.empty=Start Game
instance.launch.empty=Join Server
instance.launch.empty.installing=Installing Game
instance.launch.empty.tooltip=Install and launch the bundled server modpack
instance.launch.test=Test Launch
@@ -1564,6 +1584,9 @@ settings.launcher.download.threads=Download Threads
settings.launcher.download.threads.auto=Automatically Determine
settings.launcher.download.threads.custom=Custom
settings.launcher.download_source=Download Source
settings.download_source.default=Automatic
settings.download_source.official=Official
settings.download_source.mirror=Mirror
settings.launcher.download_source.auto=Automatically Choose Download Sources
settings.launcher.default_addon_source=Default Addon Source
settings.launcher.misc=Misc
@@ -631,6 +631,26 @@ first_run.microsoft=登录正版账号
first_run.invalid=只能用 1 到 16 个英文字母、数字或下划线。
home.server=服务器
home.server.unreachable=连接不上
home.progress.step.check=检查更新
home.progress.step.java=准备 Java
home.progress.step.sync=同步整合包
home.progress.step.join=进入服务器
home.progress.checking=正在检查更新…
home.progress.syncing=正在同步整合包 · %d%%
home.progress.failed=同步失败
home.progress.retry=重试
settings.group.game=游戏
settings.group.help=帮助
settings.java.summary=Java · 自动管理
settings.java.advanced=高级…
settings.help.about=关于
about.based_on=基于 HMCL
about.check_update=检查更新
about.check_update.latest=整合包已是最新。
about.check_update.available=有可用更新,请在首页点击“更新”。
about.check_update.not_installed=尚未安装整合包。
about.source_code=源代码
about.copy_diagnostics=复制诊断信息
home.motd=服务器公告
home.restart.title=重启游戏
home.restart.update=游戏为应用更新已退出。现在更新客户端并重新启动游戏吗?
@@ -638,7 +658,7 @@ home.restart.generic=游戏请求重启。现在更新客户端并重新启动
server.join=进入服务器
server.update=更新
instance.launch_and_enter_world=进入世界
instance.launch.empty=开始游戏
instance.launch.empty=进入服务器
instance.launch.empty.installing=安装游戏
instance.launch.empty.tooltip=安装并启动服务器整合包
instance.launch.test=测试游戏
@@ -1381,6 +1401,9 @@ settings.launcher.download.threads=下载线程数
settings.launcher.download.threads.auto=自动选择线程数
settings.launcher.download.threads.custom=自定义线程数
settings.launcher.download_source=下载源
settings.download_source.default=自动
settings.download_source.official=官方源
settings.download_source.mirror=镜像源
settings.launcher.download_source.auto=自动选择下载源
settings.launcher.default_addon_source=附加内容默认下载源
settings.launcher.misc=杂项
@@ -625,6 +625,26 @@ first_run.microsoft=登入正版帳號
first_run.invalid=只能使用 1 到 16 個英文字母、數字或底線。
home.server=伺服器
home.server.unreachable=無法連線
home.progress.step.check=檢查更新
home.progress.step.java=準備 Java
home.progress.step.sync=同步整合包
home.progress.step.join=進入伺服器
home.progress.checking=正在檢查更新…
home.progress.syncing=正在同步整合包 · %d%%
home.progress.failed=同步失敗
home.progress.retry=重試
settings.group.game=遊戲
settings.group.help=說明
settings.java.summary=Java · 自動管理
settings.java.advanced=進階…
settings.help.about=關於
about.based_on=基於 HMCL
about.check_update=檢查更新
about.check_update.latest=整合包已是最新。
about.check_update.available=有可用更新,請在首頁點擊「更新」。
about.check_update.not_installed=尚未安裝整合包。
about.source_code=原始碼
about.copy_diagnostics=複製診斷資訊
home.motd=伺服器公告
home.restart.title=重啟遊戲
home.restart.update=遊戲為套用更新已結束。現在更新客戶端並重新啟動遊戲嗎?
@@ -632,7 +652,7 @@ home.restart.generic=遊戲要求重啟。現在更新客戶端並重新啟動
server.join=進入伺服器
server.update=更新
instance.launch_and_enter_world=進入世界
instance.launch.empty=開始遊戲
instance.launch.empty=進入伺服器
instance.launch.empty.installing=安裝遊戲
instance.launch.empty.tooltip=安裝並啟動伺服器整合包
instance.launch.test=測試遊戲
@@ -1375,6 +1395,9 @@ settings.launcher.download.threads=下載執行緒數
settings.launcher.download.threads.auto=自動選取執行緒數
settings.launcher.download.threads.custom=自訂執行緒數
settings.launcher.download_source=下載來源
settings.download_source.default=自動
settings.download_source.official=官方來源
settings.download_source.mirror=鏡像來源
settings.launcher.download_source.auto=自動選取下載來源
settings.launcher.default_addon_source=附加內容預設下載來源
settings.launcher.misc=雜項