feat: UI refresh batch 1 (home progress, single settings page, slim about) #24

Merged
layue13 merged 3 commits from feat/ui-refresh-1 into main 2026-11-11 08:31:38 +00: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=雜項