diff --git a/config/src/main/java/dev/faststats/config/SimpleConfig.java b/config/src/main/java/dev/faststats/config/SimpleConfig.java index 23f5074a..1021a042 100644 --- a/config/src/main/java/dev/faststats/config/SimpleConfig.java +++ b/config/src/main/java/dev/faststats/config/SimpleConfig.java @@ -9,9 +9,9 @@ import org.jspecify.annotations.Nullable; import java.io.IOException; -import java.io.OutputStreamWriter; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Objects; import java.util.Properties; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; @@ -21,15 +21,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; @ApiStatus.Internal -public record SimpleConfig( - UUID serverId, - boolean enabled, - boolean additionalMetrics, - boolean debug, - boolean submitMetrics, - boolean errorTracking, - boolean firstRun -) implements Config { +public final class SimpleConfig implements Config { private static final int CONFIG_VERSION = 2; private static final String COMMENT = """ @@ -58,7 +50,86 @@ public record SimpleConfig( Learn more at: https://faststats.dev/info Since this is your first start with FastStats, submission will not start - until you restart the server to allow you to opt out if you prefer."""; + until after a restart, to allow you to opt out if you prefer."""; + + private final Path file; + private final UUID serverId; + private final boolean debug; + private final boolean firstRun; + private volatile boolean additionalMetrics; + private volatile boolean enabled; + private volatile boolean errorTracking; + private volatile boolean submitMetrics; + + public SimpleConfig( + final Path file, + final UUID serverId, + final boolean enabled, + final boolean additionalMetrics, + final boolean debug, + final boolean submitMetrics, + final boolean errorTracking, + final boolean firstRun + ) { + this.file = file; + this.serverId = serverId; + this.enabled = enabled; + this.additionalMetrics = additionalMetrics; + this.debug = debug; + this.submitMetrics = submitMetrics; + this.errorTracking = errorTracking; + this.firstRun = firstRun; + } + + @Override + public UUID serverId() { + return serverId; + } + + @Override + public boolean enabled() { + return enabled; + } + + public void enabled(final boolean enabled) { + this.enabled = enabled; + } + + @Override + public boolean additionalMetrics() { + return additionalMetrics; + } + + public void additionalMetrics(final boolean additionalMetrics) { + this.additionalMetrics = additionalMetrics; + } + + @Override + public boolean debug() { + return debug; + } + + @Override + public boolean submitMetrics() { + return submitMetrics; + } + + public void submitMetrics(final boolean submitMetrics) { + this.submitMetrics = submitMetrics; + } + + @Override + public boolean errorTracking() { + return errorTracking; + } + + public void errorTracking(final boolean errorTracking) { + this.errorTracking = errorTracking; + } + + public boolean firstRun() { + return firstRun; + } @Contract(mutates = "io") public static SimpleConfig read(final Path file, final LoggerFactory factory) throws RuntimeException { @@ -91,8 +162,7 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th if (configVersion != null && configVersion < CONFIG_VERSION) logger.info("Updating config version from %s to %s", configVersion, CONFIG_VERSION); Files.createDirectories(file.getParent()); - try (final var out = Files.newOutputStream(file); - final var writer = new OutputStreamWriter(out, UTF_8)) { + try (final var writer = Files.newBufferedWriter(file, UTF_8)) { final var store = new Properties(); store.setProperty("enabled", Boolean.toString(enabled)); @@ -112,6 +182,7 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th } return new SimpleConfig( + file, serverId, enabled && enabledFlag, enabled && enabledFlag && additionalMetrics, @@ -122,6 +193,23 @@ public static SimpleConfig read(final Path file, final LoggerFactory factory) th ); } + @Contract(mutates = "io") + public void persist() throws RuntimeException { + final var properties = readOrEmpty(file); + if (properties == null) throw new IllegalStateException("Metrics config has not been initialized"); + + properties.setProperty("enabled", Boolean.toString(enabled())); + properties.setProperty("submitAdditionalMetrics", Boolean.toString(additionalMetrics())); + properties.setProperty("submitErrors", Boolean.toString(errorTracking())); + properties.setProperty("submitMetrics", Boolean.toString(submitMetrics())); + + try (final var writer = Files.newBufferedWriter(file, UTF_8)) { + properties.store(writer, COMMENT); + } catch (final IOException e) { + throw new RuntimeException("Failed to save metrics config", e); + } + } + // fixme: this code sucks ass @Contract(value = "_, _, _, !null, _, _-> !null") private static @Nullable T parse( @@ -180,4 +268,33 @@ public boolean preSubmissionStart(final SimpleContext context) { } return true; } + + @Override + public boolean equals(final Object object) { + if (this == object) return true; + if (!(object instanceof final SimpleConfig that)) return false; + return enabled == that.enabled + && additionalMetrics == that.additionalMetrics + && debug == that.debug + && submitMetrics == that.submitMetrics + && errorTracking == that.errorTracking + && firstRun == that.firstRun + && Objects.equals(serverId, that.serverId); + } + + @Override + public int hashCode() { + return Objects.hash(serverId, enabled, additionalMetrics, debug, submitMetrics, errorTracking, firstRun); + } + + @Override + public String toString() { + return "SimpleConfig[serverId=" + serverId + + ", enabled=" + enabled + + ", additionalMetrics=" + additionalMetrics + + ", debug=" + debug + + ", submitMetrics=" + submitMetrics + + ", errorTracking=" + errorTracking + + ", firstRun=" + firstRun + ']'; + } } diff --git a/core/src/main/java/dev/faststats/FastStatsRegistration.java b/core/src/main/java/dev/faststats/FastStatsRegistration.java new file mode 100644 index 00000000..314d4a1f --- /dev/null +++ b/core/src/main/java/dev/faststats/FastStatsRegistration.java @@ -0,0 +1,22 @@ +package dev.faststats; + +import java.util.Set; + +/** + * A non-sensitive description of a live FastStats consumer. + * + * @since 0.29.2 + */ +public record FastStatsRegistration( + String projectName, + String sdkName, + String sdkVersion, + boolean metrics, + boolean errorTracking, + boolean featureFlags, + Set additionalMetrics +) { + public FastStatsRegistration { + additionalMetrics = Set.copyOf(additionalMetrics); + } +} diff --git a/core/src/main/java/dev/faststats/FastStatsRegistry.java b/core/src/main/java/dev/faststats/FastStatsRegistry.java new file mode 100644 index 00000000..38ef781e --- /dev/null +++ b/core/src/main/java/dev/faststats/FastStatsRegistry.java @@ -0,0 +1,78 @@ +package dev.faststats; + +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +/** + * Registry of contexts that opt into live onboarding lifecycle updates. + * + * @since 0.29.2 + */ +public final class FastStatsRegistry { + private static final FastStatsRegistry INSTANCE = new FastStatsRegistry(); + + private final Map contexts = new IdentityHashMap<>(); + private @Nullable Config config; + + private FastStatsRegistry() { + } + + @Contract(pure = true) + public static FastStatsRegistry instance() { + return INSTANCE; + } + + /** Registers a context and the shared mutable configuration used by its platform. */ + public synchronized void register(final SimpleContext context) { + if (this.config == null) this.config = context.getConfig(); + contexts.put(context, new Entry(context, context.registration())); + } + + /** Removes a context when its platform lifecycle ends. */ + public synchronized void unregister(final SimpleContext context) { + contexts.remove(context); + if (contexts.isEmpty()) config = null; + } + + /** Returns a snapshot of every registered FastStats consumer. */ + public synchronized List registrations() { + return contexts.values().stream().map(Entry::registration).toList(); + } + + /** Enables submission for all contexts using the shared configuration. */ + public void start() { + final var config = config(); + for (final var entry : entries()) { + entry.context().startSubmissions(config); + } + } + + /** Disables submission for all contexts using the shared configuration. */ + public void shutdown() { + final var config = config(); + for (final var entry : entries()) { + entry.context().stopSubmissions(config); + } + } + + private synchronized List entries() { + return new ArrayList<>(contexts.values()); + } + + public synchronized Config config() { + final var config = this.config; + if (config == null) throw new IllegalStateException("No FastStats contexts are registered"); + return config; + } + + private record Entry( + SimpleContext context, + FastStatsRegistration registration + ) { + } +} diff --git a/core/src/main/java/dev/faststats/SimpleContext.java b/core/src/main/java/dev/faststats/SimpleContext.java index 6d5a0de3..3bc7dd0b 100644 --- a/core/src/main/java/dev/faststats/SimpleContext.java +++ b/core/src/main/java/dev/faststats/SimpleContext.java @@ -12,18 +12,21 @@ import java.util.HashSet; import java.util.Optional; import java.util.Properties; +import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Function; +// todo: revise public non-sealed abstract class SimpleContext implements FastStatsContext { private final @Token String token; - private final Config config; + private volatile Config config; private final SdkInfo sdkInfo; private final LoggerFactory loggerFactory; private final Logger logger; protected volatile boolean ready = false; + private volatile boolean submissionActive; private @Nullable Metrics metrics; private @Nullable FeatureFlagService featureFlagService; @@ -56,14 +59,33 @@ protected SimpleContext(final Factory factory, final LoggerFactory loggerF @MustBeInvokedByOverriders protected final void initializeServices(final Factory factory) throws IllegalStateException { + initializeServices(factory, false); + } + + /** + * Initializes service descriptions before consent so this context can participate in a live lifecycle registry. + */ + @MustBeInvokedByOverriders + protected final void initializeManagedServices(final Factory factory) throws IllegalStateException { + initializeServices(factory, true); + } + + private void initializeServices(final Factory factory, final boolean lifecycleManaged) { if (factory.metrics == null && factory.errorTracker == null && factory.featureFlagService == null) throw new IllegalStateException("Context created without any service attached, was this intentional?"); - if (!preSubmissionStart()) return; + final var start = preSubmissionStart(); + if (!lifecycleManaged && !start) return; - this.metrics = config.submitMetrics() && factory.metrics != null ? factory.metrics.apply(metricsFactory()) : null; - this.errorTrackerService = config.errorTracking() && factory.errorTracker != null ? new SimpleErrorTrackerService(this, factory.errorTracker) : null; + if (factory.metrics != null && (lifecycleManaged || config.submitMetrics())) { + final var metricsFactory = metricsFactory(); + this.metrics = factory.metrics.apply(metricsFactory); + } + this.errorTrackerService = factory.errorTracker != null && (lifecycleManaged || config.errorTracking()) + ? new SimpleErrorTrackerService(this, factory.errorTracker) + : null; this.featureFlagService = factory.featureFlagService != null ? factory.featureFlagService.apply(new SimpleFeatureFlagService.Factory(this)) : null; + this.submissionActive = start; final var features = new HashSet(3); features.add("metrics=" + (metrics != null ? "yes" : "no")); @@ -118,13 +140,13 @@ public final Config getConfig() { @Override @Contract(pure = true) public final Optional metrics() { - return Optional.ofNullable(metrics); + return submissionActive && config.submitMetrics() ? Optional.ofNullable(metrics) : Optional.empty(); } @Override @Contract(pure = true) public final Optional featureFlagService() { - return Optional.ofNullable(featureFlagService); + return submissionActive ? Optional.ofNullable(featureFlagService) : Optional.empty(); } @Contract(value = " -> new", pure = true) @@ -138,11 +160,11 @@ protected FeatureFlagService.Factory featureFlagServiceFactory() { @Override @Contract(pure = true) public final Optional errorTrackerService() { - return Optional.ofNullable(errorTrackerService); + return submissionActive && config.errorTracking() ? Optional.ofNullable(errorTrackerService) : Optional.empty(); } @Override - public void ready() { + public synchronized void ready() { if (ready) { logger.warn("%s#ready() was called twice; ignoring.", getClass().getSimpleName()); return; @@ -155,12 +177,44 @@ public void ready() { @Async.Schedule protected abstract void scheduleAtFixedRate(Runnable task, long initialDelay, long period, TimeUnit unit); + final FastStatsRegistration registration() { + final var additionalMetrics = metrics instanceof final SimpleMetrics simpleMetrics + ? simpleMetrics.metricIds() + : Set.of(); + return new FastStatsRegistration( + getProjectName(), sdkInfo.getName(), sdkInfo.getVersion(), + metrics != null, errorTrackerService != null, featureFlagService != null, additionalMetrics + ); + } + + final synchronized void startSubmissions(final Config config) { + final var wasActive = submissionActive; + this.config = config; + loggerFactory.setDebug(config.debug()); + if ((!wasActive || !config.errorTracking()) && errorTrackerService != null) errorTrackerService.clearPending(); + submissionActive = true; + } + + final synchronized void stopSubmissions(final Config config) { + this.config = config; + loggerFactory.setDebug(config.debug()); + submissionActive = false; + if (errorTrackerService != null) errorTrackerService.clearPending(); + } + + final boolean submissionsActive() { + return submissionActive; + } + @Override - public void shutdown() { + public synchronized void shutdown() { if (!ready) return; - if (errorTrackerService != null) errorTrackerService.shutdown(); + if (submissionActive && config.errorTracking() && errorTrackerService != null) errorTrackerService.shutdown(); + else if (errorTrackerService != null) errorTrackerService.clearPending(); if (featureFlagService instanceof final SimpleFeatureFlagService service) service.shutdown(); - if (metrics instanceof final SimpleMetrics simpleMetrics) simpleMetrics.shutdown(); + if (submissionActive && config.submitMetrics() && metrics instanceof final SimpleMetrics simpleMetrics) + simpleMetrics.shutdown(); + submissionActive = false; ready = false; } diff --git a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java index 250ecfce..d2092545 100644 --- a/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java +++ b/core/src/main/java/dev/faststats/SimpleErrorTrackerService.java @@ -72,7 +72,9 @@ private static void handleUncaughtException(final Thread thread, final Throwable if (handler != null) handler.uncaughtException(thread, error); } + // todo: revise private void submit() { + if (!context.submissionsActive() || !context.getConfig().errorTracking()) return; try { final var data = createData(); if (data == null) return; @@ -128,6 +130,11 @@ protected String serverType() { return "error"; } + // todo: revise + void clearPending() { + clear(); + } + public void shutdown() { submit(); clear(); diff --git a/core/src/main/java/dev/faststats/SimpleMetrics.java b/core/src/main/java/dev/faststats/SimpleMetrics.java index 3209d333..d11bd616 100644 --- a/core/src/main/java/dev/faststats/SimpleMetrics.java +++ b/core/src/main/java/dev/faststats/SimpleMetrics.java @@ -11,8 +11,10 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; @ApiStatus.Internal +// todo: revise public abstract class SimpleMetrics extends SubmissionService implements Metrics { private static final String COLLECT_PATH = "/v1/collect"; @@ -26,7 +28,7 @@ public abstract class SimpleMetrics extends SubmissionService implements Metrics @Contract(mutates = "io") protected SimpleMetrics(final Factory factory) { super(factory.context); - this.metrics = context.getConfig().additionalMetrics() ? Set.copyOf(factory.metrics) : Set.of(); + this.metrics = Set.copyOf(factory.metrics); this.flush = factory.flush; } @@ -43,6 +45,7 @@ public boolean isClientApplication() { } private boolean submit() { + if (!context.submissionsActive() || !context.getConfig().submitMetrics()) return false; try { if (submit(url, createData(), "metrics")) { if (flush != null) flush.run(); @@ -78,6 +81,7 @@ private void appendInternalData(final JsonObject metrics) { } private void appendCustomData(final JsonObject metrics) { + if (!context.getConfig().additionalMetrics()) return; this.metrics.forEach(metric -> { try { if (metrics.has(metric.getId())) { @@ -115,6 +119,10 @@ private JsonObject createData() { @Contract(mutates = "param1") protected abstract void appendDefaultData(JsonObject metrics); + Set metricIds() { + return metrics.stream().map(Metric::getId).collect(Collectors.toUnmodifiableSet()); + } + protected void shutdown() { try { logger.info("Shutting down metrics submission"); diff --git a/fabric/build.gradle.kts b/fabric/build.gradle.kts index b0badbab..47845d78 100644 --- a/fabric/build.gradle.kts +++ b/fabric/build.gradle.kts @@ -48,6 +48,8 @@ subprojects { dependencies { compileOnlyApi(project(":core")) compileOnly(project(":config")) + compileOnly(project(":onboarding")) + include(project(":onboarding")) minecraft("com.mojang:minecraft:26.1.2") compileOnly("net.fabricmc.fabric-api:fabric-api:0.150.0+26.1.2") compileOnly("net.fabricmc:fabric-loader:0.19.3") diff --git a/fabric/example-mod/build.gradle.kts b/fabric/example-mod/build.gradle.kts index a998da26..4e64f188 100644 --- a/fabric/example-mod/build.gradle.kts +++ b/fabric/example-mod/build.gradle.kts @@ -1,6 +1,6 @@ plugins { id("net.fabricmc.fabric-loom") version "1.15-SNAPSHOT" - kotlin("jvm") + //kotlin("jvm") } java.toolchain.languageVersion = JavaLanguageVersion.of(25) @@ -10,8 +10,11 @@ tasks.compileJava { } dependencies { + compileOnly(project(":config")) + compileOnly(project(":onboarding")) compileOnly("net.fabricmc:fabric-loader:0.19.3") compileOnly(project(":fabric:versions:26.1-26.3")) + include(project(":onboarding")) include(project(":fabric:versions:26.1-26.3")) minecraft("com.mojang:minecraft:26.2") } diff --git a/fabric/example-mod/src/main/java/com/example/OnboardingScreen.java b/fabric/example-mod/src/main/java/com/example/OnboardingScreen.java new file mode 100644 index 00000000..edeb2880 --- /dev/null +++ b/fabric/example-mod/src/main/java/com/example/OnboardingScreen.java @@ -0,0 +1,527 @@ +package com.example; + +import com.mojang.blaze3d.platform.cursor.CursorTypes; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.Checkbox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.net.URI; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +@NullMarked +public abstract class OnboardingScreen { + + // todo: add a button to open the onboarding screen somewhere in the settings + + private static final class Onboarding extends Screen { + private static final String MODS_URL = "https://faststats.dev/mods"; + private static final String ABUSE_URL = "https://faststats.dev/abuse"; + private static final String INFO_URL = "https://faststats.dev/info"; + private static final Component title = Component.literal("FastStats Metrics"); + private static final Component description = Component.literal("") + .append(link("FastStats", MODS_URL)) + .append(" collects anonymous usage statistics and errors.\n") + .append("Keeping Metrics and Error tracking enabled helps developers to improve their mods.\n\n") + .append("If you suspect a developer is collecting personal data or bypassing any opt-out option,\n") + .append("please report it at: ") + .append(link(ABUSE_URL, ABUSE_URL)); + + private static final Component submitMetrics = Component.literal("Submit Metrics"); + private static final Component submitAdditionalMetrics = Component.literal("Submit Additional Metrics (provided by the developer)"); + private static final Component errorTracking = Component.literal("Submit Errors"); + + private static final int REFERENCE_WIDTH = 1280; + private static final int REFERENCE_HEIGHT = 720; + private static final int MAX_PANEL_WIDTH = 1186; + private static final int PANEL_MARGIN = 27; + private static final int PANEL_SIDE_MARGIN = 16; + private static final int TITLE_OFFSET = 45; + private static final int SCROLL_TOP_OFFSET = 88; + private static final int CONTENT_TOP_OFFSET = 122; + private static final int BOX_PADDING_X = 24; + private static final int BOX_PADDING_Y = 16; + private static final int BOX_GAP = 12; + private static final int CHECKBOX_GAP = 30; + private static final int CHECKBOX_TOP_GAP = 22; + private static final int BUTTON_TOP_GAP = 16; + private static final int BUTTON_GAP = 2; + private static final int BUTTON_HEIGHT = 20; + + private static final String[] alwaysCollectedData = new String[]{ + "Mod version", + "Fabric version", + "..." + }; + + private static final Set expandedDetails = new HashSet<>(); + + private static final Set mods = Set.of( + new Mod("TreeHugger69", new String[]{"Default Metrics", "Error Tracking"}, new String[]{"client_age", "language"}), + new Mod("Sucker123", new String[]{"Default Metrics"}, new String[]{}) + ); + + private record Mod(String name, String[] metrics, String[] additionalMetrics) { + + } + + private boolean collectedDataExpanded; + private boolean installedModsExpanded; + private boolean submitMetricsSelected = true; + private int scrollOffset; + + private Checkbox submitMetricsWidget; + private Checkbox submitAdditionalMetricsWidget; + private Checkbox errorTrackingWidget; + private Button declineButton; + private Button acceptButton; + + private Onboarding() { + super(title); + } + + @Override + protected void init() { + final var submitAdditionalMetricsSelected = new AtomicBoolean(true); + final var errorTrackingSelected = new AtomicBoolean(true); + + this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset()); + + this.submitMetricsWidget = this.addRenderableWidget(Checkbox.builder(Onboarding.submitMetrics, this.font) + .pos(contentLeft(), checkboxY()) + .onValueChange((checkbox, selected) -> { + this.submitMetricsSelected = selected; + this.rebuildWidgets(); + }) + .selected(this.submitMetricsSelected) + .build()); + + this.submitAdditionalMetricsWidget = this.addRenderableWidget(Checkbox.builder(this.additionalMetricsLabel(), this.font) + .pos(contentLeft(), checkboxY() + scaled(CHECKBOX_GAP)) + .onValueChange((checkbox, selected) -> submitAdditionalMetricsSelected.set(selected)) + .selected(submitAdditionalMetricsSelected.get() && this.submitMetricsSelected) + .build()); + this.submitAdditionalMetricsWidget.active = this.submitMetricsSelected; + this.submitAdditionalMetricsWidget.setAlpha(this.submitMetricsSelected ? 1.0F : 0.45F); + this.errorTrackingWidget = this.addRenderableWidget(Checkbox.builder(Onboarding.errorTracking, this.font) + .pos(contentLeft(), checkboxY() + scaled(CHECKBOX_GAP) * 2) + .onValueChange((checkbox, selected) -> errorTrackingSelected.set(selected)) + .selected(errorTrackingSelected.get()) + .build()); + + final var buttonGap = scaled(BUTTON_GAP); + final var buttonWidth = (contentWidth() - buttonGap) / 2; + this.declineButton = this.addRenderableWidget(Button.builder(Component.literal("Decline All").withColor(TextColor.RED), button -> { + // todo: disable all… close immediately or let the user also click "confirm"? + this.onClose(); + }).bounds(contentLeft(), buttonY(), buttonWidth, buttonHeight()).build()); + this.acceptButton = this.addRenderableWidget(Button.builder(Component.literal("Confirm Selection").withColor(TextColor.GREEN), button -> { + // todo: save selection + this.onClose(); + }).bounds(contentLeft() + buttonWidth + buttonGap, buttonY(), buttonWidth, buttonHeight()).build()); + this.updateWidgetPositions(); + } + + // fixme: this is hellish code, i bet i will forget what it does until tomorrow :) + @Override + public void extractRenderState(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float a) { + super.extractRenderState(graphics, mouseX, mouseY, a); + + final int panelWidth = panelWidth(); + final int panelHeight = panelHeight(); + final int panelX = panelX(); + final int panelY = panelY(); + final int border = 0xFFFFFFFF; + final int text = 0xFFE0E0E0; + + graphics.outline(panelX, panelY, panelWidth, panelHeight, border); + graphics.centeredText(this.font, title, this.width / 2, panelY + scaled(TITLE_OFFSET), text); + + final int contentInset = contentInset(panelWidth); + final int descriptionX = panelX + contentInset; + final int descriptionY = descriptionY(); + final int descriptionWidth = panelWidth - contentInset * 2; + final int descriptionHeight = descriptionHeight(); + graphics.enableScissor(panelX + 1, scrollTop(), panelX + panelWidth - 1, scrollBottom()); + graphics.fill(descriptionX + 1, descriptionY + 1, descriptionX + descriptionWidth - 1, descriptionY + descriptionHeight - 1, 0xFF000000); + graphics.outline(descriptionX, descriptionY, descriptionWidth, descriptionHeight, border); + graphics.textWithWordWrap(this.font, description, descriptionX + scaled(BOX_PADDING_X), descriptionY + scaled(BOX_PADDING_Y), descriptionTextWidth(), text, false); + + final int infoY = descriptionY + descriptionHeight + scaled(BOX_GAP); + final int infoHeight = infoHeight(); + graphics.fill(descriptionX + 1, infoY + 1, descriptionX + descriptionWidth - 1, infoY + infoHeight - 1, 0xFF000000); + graphics.outline(descriptionX, infoY, descriptionWidth, infoHeight, border); + graphics.textWithWordWrap(this.font, infoDescription(), descriptionX + scaled(BOX_PADDING_X), infoY + scaled(BOX_PADDING_Y), descriptionTextWidth(), text, false); + graphics.disableScissor(); + this.renderScrollbar(graphics, panelX, panelY, panelWidth, panelHeight, border); + if (this.isHoveringClickableText(mouseX, mouseY)) { + graphics.requestCursor(CursorTypes.POINTING_HAND); + } + } + + // fixme: all of this sucks + @Override + public boolean mouseClicked(final MouseButtonEvent event, final boolean doubleClick) { + final Style clickedStyle = this.getStyleAt(description, event.x(), event.y(), descriptionTextX(), descriptionTextY(), descriptionTextWidth()); + if (clickedStyle != null && clickedStyle.getClickEvent() != null) { + defaultHandleClickEvent(clickedStyle.getClickEvent(), this.minecraft, this); + return true; + } + + if (event.y() < scrollTop() || event.y() > scrollBottom()) { + return super.mouseClicked(event, doubleClick); + } + + final Component info = infoDescription(); + final Style clickedInfoStyle = this.getStyleAt(info, event.x(), event.y(), infoTextX(), infoTextY(), descriptionTextWidth()); + if (clickedInfoStyle != null && clickedInfoStyle.getClickEvent() != null) { + defaultHandleClickEvent(clickedInfoStyle.getClickEvent(), this.minecraft, this); + return true; + } + + final String clickedInfoLine = this.getLineAt(info, event.x(), event.y(), infoTextX(), infoTextY(), descriptionTextWidth()); + if (clickedInfoLine != null) { + if (clickedInfoLine.contains("What data is collected?")) { + this.collectedDataExpanded = !this.collectedDataExpanded; + this.updateWidgetPositions(); + return true; + } + if (clickedInfoLine.contains("List of installed mods that use FastStats")) { + this.installedModsExpanded = !this.installedModsExpanded; + this.updateWidgetPositions(); + return true; + } + if (clickedInfoLine.contains("Additional Metrics")) { + expandedDetails.add(""); // todo: idk how to expand that crap + this.updateWidgetPositions(); + return true; + } + } + + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseScrolled(final double mouseX, final double mouseY, final double horizontalAmount, final double verticalAmount) { + final int maxScrollOffset = this.maxScrollOffset(); + if (maxScrollOffset <= 0) { + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); + } + + final var b = this.scrollOffset - (int) (verticalAmount * 20); + this.scrollOffset = Math.clamp(b, 0, maxScrollOffset); + this.updateWidgetPositions(); + return true; + } + + private boolean isHoveringClickableText(final double mouseX, final double mouseY) { + if (mouseY < scrollTop() || mouseY > scrollBottom()) { + return false; + } + + final Style descriptionStyle = this.getStyleAt(description, mouseX, mouseY, descriptionTextX(), descriptionTextY(), descriptionTextWidth()); + if (descriptionStyle != null && descriptionStyle.getClickEvent() != null) { + return true; + } + + final Component info = infoDescription(); + final Style infoStyle = this.getStyleAt(info, mouseX, mouseY, infoTextX(), infoTextY(), descriptionTextWidth()); + if (infoStyle != null && infoStyle.getClickEvent() != null) { + return true; + } + + // fixme: do not hardcode any of this, we need a proper solution here + final String infoLine = this.getLineAt(info, mouseX, mouseY, infoTextX(), infoTextY(), descriptionTextWidth()); + return infoLine != null && (infoLine.contains("What data is collected?") + || infoLine.contains("List of installed mods that use FastStats") + || infoLine.contains("Additional Metrics")); + } + + // fixme: hacky bs + private @Nullable Style getStyleAt(final Component component, final double mouseX, final double mouseY, final int textX, final int textY, final int textWidth) { + final var line = this.getFormattedLineAt(component, mouseX, mouseY, textX, textY, textWidth); + if (line == null) { + return null; + } + + final var relativeX = mouseX - textX; + final var currentX = new double[]{0}; + final var style = line.visit((segmentStyle, text) -> { + final float segmentWidth = this.font.getSplitter().stringWidth(FormattedText.of(text, segmentStyle)); + if (relativeX >= currentX[0] && relativeX <= currentX[0] + segmentWidth) { + return Optional.of(segmentStyle); + } + + currentX[0] += segmentWidth; + return Optional.empty(); + }, Style.EMPTY); + + return style.orElse(null); + } + + private @Nullable String getLineAt(final Component component, final double mouseX, final double mouseY, final int textX, final int textY, final int textWidth) { + final FormattedText line = this.getFormattedLineAt(component, mouseX, mouseY, textX, textY, textWidth); + return line == null ? null : line.getString(); + } + + private @Nullable FormattedText getFormattedLineAt(final Component component, final double mouseX, final double mouseY, final int textX, final int textY, final int textWidth) { + if (mouseX < textX || mouseX > textX + textWidth || mouseY < textY) { + return null; + } + + final List lines = this.font.getSplitter().splitLines(component, textWidth, Style.EMPTY); + final int line = (int) ((mouseY - textY) / this.font.lineHeight); + if (line < 0 || line >= lines.size()) { + return null; + } + + return lines.get(line); + } + + private int descriptionTextX() { + final int panelWidth = panelWidth(); + final int panelX = panelX(); + final int contentInset = contentInset(panelWidth); + return panelX + contentInset + scaled(BOX_PADDING_X); + } + + private int descriptionTextY() { + return descriptionY() + scaled(BOX_PADDING_Y); + } + + private int infoTextX() { + return descriptionTextX(); + } + + private int infoTextY() { + return infoY() + scaled(BOX_PADDING_Y); + } + + private int contentLeft() { + final int panelWidth = panelWidth(); + final int panelX = panelX(); + final int contentInset = contentInset(panelWidth); + return panelX + contentInset + 2; + } + + private int contentWidth() { + final int panelWidth = panelWidth(); + final int contentInset = contentInset(panelWidth); + return panelWidth - contentInset * 2 - 4; + } + + private int descriptionY() { + return contentTop() - this.scrollOffset; + } + + private int baseInfoY() { + return contentTop() + descriptionHeight() + scaled(BOX_GAP) - this.scrollOffset; + } + + private int infoY() { + return this.baseInfoY(); + } + + private int scrollTop() { + return panelY() + scaled(SCROLL_TOP_OFFSET); + } + + private int scrollBottom() { + return panelY() + panelHeight() - 1; + } + + private int checkboxY() { + return this.infoY() + infoHeight() + scaled(CHECKBOX_TOP_GAP); + } + + private int buttonY() { + return checkboxY() + scaled(CHECKBOX_GAP) * 2 + buttonHeight() + scaled(BUTTON_TOP_GAP); + } + + private int contentBottom() { + return contentTop() + descriptionHeight() + scaled(BOX_GAP) + infoHeight() + scaled(CHECKBOX_TOP_GAP) + + scaled(CHECKBOX_GAP) * 2 + buttonHeight() + scaled(BUTTON_TOP_GAP) + buttonHeight(); + } + + private int maxScrollOffset() { + return Math.max(0, contentBottom() - scrollBottom()); + } + + private void updateWidgetPositions() { + this.scrollOffset = Math.min(this.scrollOffset, this.maxScrollOffset()); + + final int buttonGap = scaled(BUTTON_GAP); + final int buttonWidth = (contentWidth() - buttonGap) / 2; + this.submitMetricsWidget.setY(checkboxY()); + this.submitAdditionalMetricsWidget.setY(checkboxY() + scaled(CHECKBOX_GAP)); + this.errorTrackingWidget.setY(checkboxY() + scaled(CHECKBOX_GAP) * 2); + this.submitMetricsWidget.visible = isInsideScrollArea(this.submitMetricsWidget.getY(), this.submitMetricsWidget.getHeight()); + this.submitAdditionalMetricsWidget.visible = isInsideScrollArea(this.submitAdditionalMetricsWidget.getY(), this.submitAdditionalMetricsWidget.getHeight()); + this.errorTrackingWidget.visible = isInsideScrollArea(this.errorTrackingWidget.getY(), this.errorTrackingWidget.getHeight()); + this.declineButton.setX(contentLeft()); + this.declineButton.setY(buttonY()); + this.declineButton.setWidth(buttonWidth); + this.declineButton.setHeight(buttonHeight()); + this.acceptButton.setX(contentLeft() + buttonWidth + buttonGap); + this.acceptButton.setY(buttonY()); + this.acceptButton.setWidth(buttonWidth); + this.acceptButton.setHeight(buttonHeight()); + this.declineButton.visible = isInsideScrollArea(this.declineButton.getY(), this.declineButton.getHeight()); + this.acceptButton.visible = isInsideScrollArea(this.acceptButton.getY(), this.acceptButton.getHeight()); + } + + private Component additionalMetricsLabel() { + if (this.submitMetricsSelected) { + return Onboarding.submitAdditionalMetrics; + } + + return Onboarding.submitAdditionalMetrics.copy().withStyle(ChatFormatting.GRAY); + } + + private boolean isInsideScrollArea(final int y, final int height) { + return y + height > scrollTop() && y < scrollBottom(); + } + + // fixme: there has to be some inbuilt way to do this, right? RIGHT??? + private void renderScrollbar(final GuiGraphicsExtractor graphics, final int panelX, final int panelY, final int panelWidth, final int panelHeight, final int color) { + final int maxScrollOffset = this.maxScrollOffset(); + if (maxScrollOffset <= 0) { + return; + } + + final int trackX = panelX + panelWidth - 10; + final int trackY = scrollTop(); + final int trackHeight = scrollBottom() - scrollTop(); + final int contentHeight = contentBottom() - scrollTop(); + final int thumbHeight = Math.clamp((long) trackHeight * trackHeight / contentHeight, 18, trackHeight); + final int thumbY = trackY + this.scrollOffset * (trackHeight - thumbHeight) / maxScrollOffset; + graphics.fill(trackX, trackY, trackX + 2, trackY + trackHeight, 0x55FFFFFF); + graphics.fill(trackX - 1, thumbY, trackX + 3, thumbY + thumbHeight, color); + } + + private int descriptionTextWidth() { + final int panelWidth = panelWidth(); + final int contentInset = contentInset(panelWidth); + final int descriptionWidth = panelWidth - contentInset * 2; + return Math.max(1, descriptionWidth - scaled(BOX_PADDING_X) * 2); + } + + private int descriptionHeight() { + return Math.max(scaled(76), this.height / 5); + } + + private int infoHeight() { + return Math.max(scaled(60), this.font.wordWrapHeight(infoDescription(), descriptionTextWidth()) + scaled(BOX_PADDING_Y) * 2); + } + + // todo: maybe an object that keeps track of that? collapsed text and everything… + private Component infoDescription() { + final MutableComponent component = Component.literal("") + .append(collapsible("What data is collected?", this.collectedDataExpanded)); + if (this.collectedDataExpanded) { + for (final var item : alwaysCollectedData) { + component.append("\n· ").append(item); + } + component.append("\nRead more here: ") + .append(link(INFO_URL, INFO_URL)); + } + + component.append("\n\n") + .append(collapsible("List of installed mods that use FastStats", this.installedModsExpanded)); + if (this.installedModsExpanded) mods.forEach(mod -> { + component.append("\n· ").append(mod.name()); + + for (final var metric : mod.metrics()) { + component.append("\n ").append(metric); + } + + final var length = mod.additionalMetrics().length; + if (length == 0) return; + + final boolean expanded = expandedDetails.contains(mod.name() + "-additional"); // todo: still no toggle + final var collapsible = collapsible("Additional Metrics (" + length + ")", expanded); + component.append("\n ").append(collapsible); + if (expanded) for (final var metric : mod.additionalMetrics()) { + component.append("\n · ").append(metric); + } + }); + + return component; + } + + private int panelX() { + return (this.width - panelWidth()) / 2; + } + + private int panelY() { + return scaled(PANEL_MARGIN); + } + + private int panelWidth() { + final int margin = scaled(PANEL_SIDE_MARGIN); + final int availableWidth = Math.max(1, this.width - margin * 2); + return Math.min(availableWidth, scaled(MAX_PANEL_WIDTH)); + } + + private int panelHeight() { + return Math.max(1, this.height - panelY() * 2); + } + + private int contentTop() { + return panelY() + scaled(CONTENT_TOP_OFFSET); + } + + private int buttonHeight() { + return Math.max(BUTTON_HEIGHT, scaled(BUTTON_HEIGHT)); + } + + private int scaled(final int value) { + return Math.max(1, Math.round(value * this.layoutScale())); + } + + private float layoutScale() { + if (this.width <= 0 || this.height <= 0) { + return 1.0F; + } + + final float widthScale = this.width / (float) REFERENCE_WIDTH; + final float heightScale = this.height / (float) REFERENCE_HEIGHT; + return Math.clamp(Math.min(widthScale, heightScale), 0.65F, 1.25F); + } + + private int contentInset(final int panelWidth) { + return Math.clamp(panelWidth / 8, scaled(24), scaled(160)); + } + + private static Component collapsible(final String text, final boolean expanded) { + return Component.literal((expanded ? "˅ " : "˃ ") + text) + .withStyle(ChatFormatting.YELLOW); + } + + private static Component link(final String text, final String url) { + return Component.literal(text).withStyle(style -> style + .withClickEvent(new ClickEvent.OpenUrl(URI.create(url))) + .withColor(ChatFormatting.AQUA) + .withUnderlined(true)); + } + + @Override + public boolean shouldCloseOnEsc() { + return false; + } + } +} diff --git a/fabric/example-mod/src/main/java/com/example/mixin/TelemetryScreenMixin.java b/fabric/example-mod/src/main/java/com/example/mixin/TelemetryScreenMixin.java new file mode 100644 index 00000000..50cf8a6f --- /dev/null +++ b/fabric/example-mod/src/main/java/com/example/mixin/TelemetryScreenMixin.java @@ -0,0 +1,35 @@ +package com.example.mixin; + +import com.llamalad7.mixinextras.sugar.Local; +import dev.faststats.screen.onboarding.OnboardingDefinition; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.layouts.LinearLayout; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.telemetry.TelemetryInfoScreen; +import net.minecraft.network.chat.Component; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(TelemetryInfoScreen.class) +public final class TelemetryScreenMixin { + @Inject( + method = "init", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/client/gui/layouts/HeaderAndFooterLayout;visitWidgets(Ljava/util/function/Consumer;)V" + ) + ) + private void addFastStatsButton( + final CallbackInfo ci, + @Local(name = "upperContentButtons") final LinearLayout upperContentButtons + ) { + upperContentButtons.addChild(Button.builder(Component.literal("FastStats"), _ -> { + OnboardingDefinition.create().onClose(() -> { + Minecraft.getInstance().setScreenAndShow((Screen) (Object) this); + }).open(); + }).build()); + } +} diff --git a/fabric/example-mod/src/main/java/com/example/mixin/TitleScreenMixin.java b/fabric/example-mod/src/main/java/com/example/mixin/TitleScreenMixin.java new file mode 100644 index 00000000..305032ce --- /dev/null +++ b/fabric/example-mod/src/main/java/com/example/mixin/TitleScreenMixin.java @@ -0,0 +1,26 @@ +package com.example.mixin; + +import dev.faststats.FastStatsRegistry; +import dev.faststats.config.SimpleConfig; +import dev.faststats.screen.onboarding.OnboardingDefinition; +import net.minecraft.client.gui.screens.TitleScreen; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(TitleScreen.class) +public final class TitleScreenMixin { + private static volatile @Unique boolean onboardingOpened = false; + + @SuppressWarnings("IllegalDependencyOnInternalPackage") + @Inject(method = "init", at = @At("TAIL")) + private void openOnboardingScreen(final CallbackInfo ci) { + if (onboardingOpened) return; + final var config = (SimpleConfig) FastStatsRegistry.instance().config(); + if (!config.firstRun()) return; + OnboardingDefinition.create().open(); + onboardingOpened = true; + } +} diff --git a/fabric/example-mod/src/main/resources/example-mod.mixins.json b/fabric/example-mod/src/main/resources/example-mod.mixins.json new file mode 100644 index 00000000..a61b6c83 --- /dev/null +++ b/fabric/example-mod/src/main/resources/example-mod.mixins.json @@ -0,0 +1,12 @@ +{ + "required": true, + "package": "com.example.mixin", + "compatibilityLevel": "JAVA_25", + "client": [ + "TelemetryScreenMixin", + "TitleScreenMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/fabric/example-mod/src/main/resources/fabric.mod.json b/fabric/example-mod/src/main/resources/fabric.mod.json index fa32a0e8..c71287d3 100644 --- a/fabric/example-mod/src/main/resources/fabric.mod.json +++ b/fabric/example-mod/src/main/resources/fabric.mod.json @@ -12,6 +12,9 @@ "com.example.ExampleMod" ] }, + "mixins": [ + "example-mod.mixins.json" + ], "depends": { "fabricloader": ">=0.18.4", "minecraft": "*", diff --git a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java index 4bf33bcb..8250ed37 100644 --- a/fabric/src/main/java/dev/faststats/fabric/FabricContext.java +++ b/fabric/src/main/java/dev/faststats/fabric/FabricContext.java @@ -1,5 +1,6 @@ package dev.faststats.fabric; +import dev.faststats.FastStatsRegistry; import dev.faststats.Metrics; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; @@ -13,8 +14,9 @@ import net.fabricmc.loader.api.ModContainer; import org.jetbrains.annotations.Contract; -import java.util.Set; +import java.nio.file.Path; import java.util.ServiceLoader; +import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -37,16 +39,15 @@ public final class FabricContext extends SimpleContext { private final ModContainer mod; private FabricContext(final Factory factory, final dev.faststats.internal.LoggerFactory loggerFactory, final String modId, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(FabricLoader.getInstance().getConfigDir() - .resolve("faststats").resolve("config.properties"), loggerFactory - ), "fabric", token); + super(factory, loggerFactory, SimpleConfig.read(configPath(), loggerFactory), "fabric", token); this.mod = FabricLoader.getInstance().getModContainer(modId).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); this.compatibilityLayer = ServiceLoader.load(CompatibilityLayer.class) .findFirst() .orElseThrow(() -> new IllegalStateException("No Fabric compatibility layer found")); - initializeServices(factory); + initializeManagedServices(factory); + FastStatsRegistry.instance().register(this); switch (FabricLoader.getInstance().getEnvironmentType()) { case CLIENT -> { ready(); @@ -90,12 +91,17 @@ public void scheduleAtFixedRate(final Runnable task, final long initialDelay, fi @Override public void shutdown() { + FastStatsRegistry.instance().unregister(this); super.shutdown(); tasks.forEach(task -> task.cancel(true)); tasks.clear(); executor.shutdown(); } + private static Path configPath() { + return FabricLoader.getInstance().getConfigDir().resolve("faststats").resolve("config.properties"); + } + public static final class Factory extends SimpleContext.Factory { private final String modId; private final @Token String token; diff --git a/fabric/src/main/java/dev/faststats/fabric/screen/FabricScreenManager.java b/fabric/src/main/java/dev/faststats/fabric/screen/FabricScreenManager.java new file mode 100644 index 00000000..eabe52cd --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/screen/FabricScreenManager.java @@ -0,0 +1,583 @@ +package dev.faststats.fabric.screen; + +import dev.faststats.screen.Button; +import dev.faststats.screen.Checkbox; +import dev.faststats.screen.Division; +import dev.faststats.screen.Element; +import dev.faststats.screen.Screen; +import dev.faststats.screen.ScreenManager; +import dev.faststats.screen.Scrollable; +import dev.faststats.screen.ScrollableTextBox; +import dev.faststats.screen.Text; +import dev.faststats.screen.TextBox; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractScrollArea; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.MultiLineTextWidget; +import net.minecraft.client.gui.components.ScrollableLayout; +import net.minecraft.client.gui.components.StringWidget; +import net.minecraft.client.gui.layouts.FrameLayout; +import net.minecraft.client.gui.layouts.Layout; +import net.minecraft.client.gui.layouts.LayoutElement; +import net.minecraft.client.gui.layouts.LinearLayout; +import net.minecraft.client.gui.layouts.SpacerElement; +import net.minecraft.client.gui.narration.NarrationElementOutput; +import net.minecraft.network.chat.Component; + +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; + +public final class FabricScreenManager implements ScreenManager { + private static final float DISABLED_ALPHA = 0.45F; + private static final int BOTTOM_CONTENT_MARGIN = 10; + private static final int BOTTOM_SECTION_GAP = 10; + private static final int CONTENT_MARGIN = 16; + private static final int CONTENT_MAX_WIDTH = 600; + private static final int FOOTER_HEIGHT = 38; + private static final int FOOTER_MAX_WIDTH = 316; + private static final int HEADER_HEIGHT = 34; + private static final int PANEL_BACKGROUND = 0xD0000000; + private static final int PANEL_BORDER = 0xFFFFFFFF; + private static final int ROOT_GAP = 16; + private static final int SCROLLBAR_RESERVE = 20; + private static final int SEPARATOR_COLOR = 0xFF404040; + private static final int TEXT_PADDING_X = 24; + private static final int TEXT_PADDING_Y = 16; + + @Override + public Text newText() { + return new FabricText(Component.empty()); + } + + @Override + public Text translatable(final String text) { + return new FabricText(Component.translatable(text)); + } + + @Override + @SuppressWarnings("DataFlowIssue") + public void closeScreen(final Screen screen) { + screen.onClose().ifPresentOrElse(Runnable::run, () -> Minecraft.getInstance().setScreenAndShow(null)); + } + + @Override + public void openScreen(final Screen screen) { + Minecraft.getInstance().setScreenAndShow(wrap(screen)); + } + + private static Component component(final Text text) { + return ((FabricText) text).text(); + } + + private net.minecraft.client.gui.screens.Screen wrap(final Screen screen) { + return new WrappedScreen(screen); + } + + private static final class WrappedScreen extends net.minecraft.client.gui.screens.Screen { + private final Screen screen; + private final Map savedScrollAmounts = new IdentityHashMap<>(); + private final Map scrollAreas = new IdentityHashMap<>(); + private final List scrollAreasByPriority = new ArrayList<>(); + + private WrappedScreen(final Screen screen) { + super(component(screen.title())); + this.screen = screen; + } + + @Override + protected void init() { + scrollAreas.clear(); + scrollAreasByPriority.clear(); + + final int topSeparator = topSeparator(); + final int bottomSeparator = bottomSeparator(); + final int contentWidth = clamp(width - CONTENT_MARGIN * 2, 1, CONTENT_MAX_WIDTH); + final int contentX = (width - contentWidth) / 2; + final int contentY = topSeparator + CONTENT_MARGIN; + final int contentHeight = Math.max(1, bottomSeparator - contentY - BOTTOM_CONTENT_MARGIN); + final int titleHeight = font.lineHeight; + + final var title = frame(width, titleHeight, + new StringWidget(component(screen.title()), font), 0.5F, 0.5F); + title.setPosition(0, Math.max(0, (topSeparator - titleHeight) / 2)); + title.visitWidgets(this::addRenderableWidget); + + final Layout bottom; + final int bottomHeight; + if (screen.bottom().isEmpty()) { + bottom = null; + bottomHeight = 0; + } else { + bottom = buildRootDocument(screen.bottom(), contentWidth, contentHeight); + bottomHeight = bottom.getHeight(); + } + + final int mainHeight = Math.max(1, + contentHeight - bottomHeight - (bottomHeight == 0 ? 0 : BOTTOM_SECTION_GAP)); + final var content = buildRootLayout(screen.elements(), contentWidth, mainHeight); + content.setPosition(contentX, contentY); + content.visitWidgets(this::addRenderableWidget); + + if (bottom != null) { + bottom.setPosition(contentX, contentY + contentHeight - bottomHeight); + bottom.visitWidgets(this::addRenderableWidget); + } + + if (!screen.footer().isEmpty()) { + final int footerWidth = clamp(width - CONTENT_MARGIN * 2, 1, FOOTER_MAX_WIDTH); + final var footer = buildRootDocument(screen.footer(), footerWidth, FOOTER_HEIGHT); + footer.setPosition((width - footerWidth) / 2, + bottomSeparator + Math.max(0, (height - bottomSeparator - footer.getHeight()) / 2)); + footer.visitWidgets(this::addRenderableWidget); + } + + scrollAreas.forEach((element, area) -> + area.setScrollAmount(savedScrollAmounts.getOrDefault(element, 0.0))); + } + + @Override + public void extractRenderState(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float a) { + graphics.fill(0, topSeparator(), width, topSeparator() + 1, SEPARATOR_COLOR); + graphics.fill(0, bottomSeparator(), width, bottomSeparator() + 1, SEPARATOR_COLOR); + super.extractRenderState(graphics, mouseX, mouseY, a); + } + + @Override + protected void repositionElements() { + saveScrollAmounts(); + super.repositionElements(); + } + + @Override + public void onClose() { + screen.onClose().ifPresentOrElse(Runnable::run, super::onClose); + } + + @Override + public boolean mouseScrolled(final double mouseX, final double mouseY, final double horizontalAmount, final double verticalAmount) { + final var rootScrollArea = scrollAreas.get(screen); + if (rootScrollArea != null && !rootScrollArea.isMouseOver(mouseX, mouseY)) { + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); + } + + for (int index = scrollAreasByPriority.size() - 1; index >= 0; index--) { + final var scrollArea = scrollAreasByPriority.get(index); + if (scrollArea.maxScrollAmount() > 0 && scrollArea.isMouseOver(mouseX, mouseY)) { + return scrollArea.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); + } + } + return super.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount); + } + + private void saveScrollAmounts() { + scrollAreas.forEach((element, area) -> savedScrollAmounts.put(element, area.scrollAmount())); + } + + private Layout buildRootLayout(final List> elements, final int width, final int availableHeight) { + if (!elements.isEmpty() && elements.stream().allMatch(ScrollableTextBox.class::isInstance)) { + return buildFlexibleTextBoxLayout(elements, width, availableHeight); + } + + final var content = buildRootDocument(elements, width, availableHeight); + if (content.getHeight() <= availableHeight) return content; + + scrollAreas.clear(); + scrollAreasByPriority.clear(); + final int documentWidth = scrollContentWidth(width); + return wrapScrollable(screen, buildRootDocument(elements, documentWidth, availableHeight), + width, availableHeight); + } + + private Layout buildFlexibleTextBoxLayout(final List> elements, + final int width, final int availableHeight) { + final int gap = elements.size() >= 2 ? ROOT_GAP : 0; + final int available = Math.max(0, availableHeight - gap * Math.max(0, elements.size() - 1)); + final int[] preferredHeights = new int[elements.size()]; + int preferredTotal = 0; + for (int index = 0; index < elements.size(); index++) { + final var textBox = (ScrollableTextBox) elements.get(index); + preferredHeights[index] = naturalScrollableTextBoxHeight(textBox.text(), width); + preferredTotal += preferredHeights[index]; + } + + final int[] heights = preferredTotal <= available + ? preferredHeights + : distributeCapped(available, elements, preferredHeights); + final var layout = LinearLayout.vertical().spacing(gap); + layout.defaultCellSetting().alignHorizontallyLeft(); + for (int index = 0; index < elements.size(); index++) { + layout.addChild(wrapSized(elements.get(index), width, heights[index])); + } + layout.arrangeElements(); + return frame(width, 0, layout, 0.0F, 0.0F); + } + + private int[] distributeCapped(final int available, final List> elements, + final int[] preferredSizes) { + final int[] result = new int[elements.size()]; + final boolean[] fixed = new boolean[elements.size()]; + int remaining = available; + int remainingWeight = totalHeightWeight(elements, fixed); + + boolean changed; + do { + changed = false; + for (int index = 0; index < elements.size(); index++) { + if (fixed[index]) continue; + final int weight = Math.max(1, elements.get(index).height()); + final int share = remainingWeight == 0 ? 0 + : (int) ((long) remaining * weight / remainingWeight); + if (preferredSizes[index] <= share) { + result[index] = preferredSizes[index]; + remaining -= preferredSizes[index]; + fixed[index] = true; + remainingWeight -= weight; + changed = true; + } + } + } while (changed && remainingWeight > 0); + + int assigned = 0; + int cumulativeWeight = 0; + for (int index = 0; index < elements.size(); index++) { + if (fixed[index]) continue; + cumulativeWeight += Math.max(1, elements.get(index).height()); + final int cumulativeSize = remainingWeight == 0 ? remaining + : (int) ((long) remaining * cumulativeWeight / remainingWeight); + result[index] = Math.max(0, cumulativeSize - assigned); + assigned = cumulativeSize; + } + return result; + } + + private int totalHeightWeight(final List> elements, final boolean[] fixed) { + int result = 0; + for (int index = 0; index < elements.size(); index++) { + if (!fixed[index]) result += Math.max(1, elements.get(index).height()); + } + return result; + } + + private Layout buildRootDocument(final List> elements, final int width, final int availableHeight) { + final List children = new ArrayList<>(elements.size()); + int childrenHeight = 0; + for (final var element : elements) { + final var child = wrapNatural(element, percentage(width, element.width()), availableHeight); + children.add(child); + childrenHeight += child.getHeight(); + } + + final int gap = elements.size() >= 2 ? clamp((availableHeight - childrenHeight) / (elements.size() - 1), 0, ROOT_GAP) : 0; + final var layout = LinearLayout.vertical().spacing(gap); + layout.defaultCellSetting().alignHorizontallyLeft(); + children.forEach(layout::addChild); + layout.arrangeElements(); + return frame(width, 0, layout, 0.0F, 0.0F); + } + + private Layout buildSizedLayout(final List> elements, final Division.Orientation orientation, + final int width, final int height, final int gap) { + final var layout = orientation == Division.Orientation.HORIZONTAL + ? LinearLayout.horizontal() + : LinearLayout.vertical(); + layout.spacing(Math.max(0, gap)); + if (orientation == Division.Orientation.HORIZONTAL) { + layout.defaultCellSetting().alignVerticallyMiddle(); + } else { + layout.defaultCellSetting().alignHorizontallyCenter(); + } + + final int available = Math.max(0, + (orientation == Division.Orientation.HORIZONTAL ? width : height) + - Math.max(0, elements.size() - 1) * Math.max(0, gap)); + final int[] sizes = distribute(available, elements, orientation); + for (int index = 0; index < elements.size(); index++) { + final var element = elements.get(index); + final int childWidth; + final int childHeight; + if (orientation == Division.Orientation.HORIZONTAL) { + childWidth = sizes[index]; + childHeight = percentage(height, element.height()); + } else { + childWidth = percentage(width, element.width()); + childHeight = sizes[index]; + } + layout.addChild(wrapSized(element, childWidth, childHeight)); + } + + if (elements.isEmpty()) { + layout.addChild(new SpacerElement(width, height)); + } + layout.arrangeElements(); + return layout; + } + + private LayoutElement wrapSized(final Element element, int width, int height) { + width = Math.max(1, width); + height = Math.max(1, height); + if (element instanceof final Division division) { + return buildSizedLayout(division.elements(), division.orientation(), width, height, division.gap()); + } else if (element instanceof final Scrollable scrollable) { + return wrapScrollable(element, + buildDocument(scrollable.elements(), width, height, scrollable.gap()), width, height); + } else if (element instanceof final ScrollableTextBox textBox) { + return wrapScrollableTextBox(element, textBox.text(), width, height); + } else if (element instanceof final TextBox textBox) { + return wrapTextBox(textBox.text(), width, height); + } else if (element instanceof final Checkbox checkbox) { + return frame(width, height, wrap(checkbox, width), 0.0F, 0.5F); + } else if (element instanceof final Button button) { + return frame(width, height, wrap(button, width, height), 0.0F, 0.5F); + } + throw new IllegalArgumentException("Unsupported screen element: " + element.getClass().getName()); + } + + private Layout buildDocument(final List> elements, final int width, final int viewportHeight, final int gap) { + final int innerWidth = Math.max(1, width); + final var layout = LinearLayout.vertical().spacing(Math.max(0, gap)); + layout.defaultCellSetting().alignHorizontallyLeft(); + for (final var element : elements) { + layout.addChild(wrapNatural(element, percentage(innerWidth, element.width()), viewportHeight)); + } + layout.arrangeElements(); + return frame(innerWidth, 0, layout, 0.0F, 0.0F); + } + + private LayoutElement wrapNatural(final Element element, int width, final int referenceHeight) { + width = Math.max(1, width); + if (element instanceof final Division division) { + return buildNaturalDivision(division, width, referenceHeight); + } else if (element instanceof final Scrollable scrollable) { + final int height = percentage(referenceHeight, scrollable.height()); + return wrapScrollable(element, + buildDocument(scrollable.elements(), width, height, scrollable.gap()), width, height); + } else if (element instanceof final ScrollableTextBox textBox) { + final int maxHeight = percentage(referenceHeight, textBox.height()); + final int height = Math.min(maxHeight, naturalScrollableTextBoxHeight(textBox.text(), width)); + return wrapScrollableTextBox(element, textBox.text(), width, height); + } else if (element instanceof final TextBox textBox) { + final int innerWidth = Math.max(1, width - TEXT_PADDING_X * 2); + final var widget = textWidget(textBox.text(), innerWidth); + final int naturalHeight = widget.getHeight() + TEXT_PADDING_Y * 2; + final int height = textBox.height() == 100 + ? naturalHeight + : percentage(referenceHeight, textBox.height()); + return wrapTextBox(textBox.text(), width, height); + } else if (element instanceof final Checkbox checkbox) { + return wrap(checkbox, width); + } else if (element instanceof final Button button) { + return wrap(button, width, net.minecraft.client.gui.components.Button.DEFAULT_HEIGHT); + } + throw new IllegalArgumentException("Unsupported screen element: " + element.getClass().getName()); + } + + private Layout buildNaturalDivision(final Division division, final int width, final int referenceHeight) { + if (division.orientation() == Division.Orientation.VERTICAL) { + final var layout = LinearLayout.vertical().spacing(Math.max(0, division.gap())); + layout.defaultCellSetting().alignHorizontallyLeft(); + for (final var element : division.elements()) { + layout.addChild(wrapNatural(element, percentage(width, element.width()), referenceHeight)); + } + layout.arrangeElements(); + return frame(width, 0, layout, 0.0F, 0.0F); + } + + final var layout = LinearLayout.horizontal().spacing(Math.max(0, division.gap())); + layout.defaultCellSetting().alignVerticallyMiddle(); + final int available = Math.max(0, + width - Math.max(0, division.elements().size() - 1) * Math.max(0, division.gap())); + final int[] widths = distribute(available, division.elements(), Division.Orientation.HORIZONTAL); + for (int index = 0; index < division.elements().size(); index++) { + final var child = wrapNatural(division.elements().get(index), widths[index], referenceHeight); + layout.addChild(frame(widths[index], child.getHeight(), child, 0.0F, 0.5F)); + } + layout.arrangeElements(); + return layout; + } + + private LayoutElement wrapTextBox(final Text text, final int width, final int height) { + final int paddingX = clamp((width - 1) / 2, 0, TEXT_PADDING_X); + final int paddingY = textPaddingY(height); + final var widget = textWidget(text, Math.max(1, width - paddingX * 2)); + widget.setMaxRows(Math.max(1, (height - paddingY * 2) / font.lineHeight)); + final var panel = new FrameLayout(width, height); + panel.addChild(new PanelWidget(width, height)); + panel.addChild(widget, settings -> settings.padding(paddingX, paddingY).align(0.0F, 0.0F)); + panel.arrangeElements(); + return panel; + } + + private LayoutElement wrapScrollableTextBox(final Element element, final Text text, final int width, final int height) { + final int panelWidth = Math.max(1, width - AbstractScrollArea.SCROLLBAR_WIDTH); + final int paddingX = clamp((panelWidth - 1) / 2, 0, TEXT_PADDING_X); + final int paddingY = textPaddingY(height); + final int textWidth = Math.max(1, panelWidth - paddingX * 2); + final int documentWidth = scrollContentWidth(width); + final int documentPaddingLeft = Math.max(1, paddingX - SCROLLBAR_RESERVE / 2); + final var textWidget = textWidget(text, textWidth); + final var document = new FrameLayout(documentWidth, textWidget.getHeight() + (paddingY + 1) * 2); + document.addChild(textWidget, settings -> settings + .paddingLeft(documentPaddingLeft) + .paddingVertical(paddingY + 1) + .align(0.0F, 0.0F)); + document.arrangeElements(); + final var scrollable = wrapScrollable(element, document, width, height); + final var panel = new FrameLayout(width, height); + panel.addChild(new PanelWidget(panelWidth, height, true, false), settings -> settings.align(0.0F, 0.0F)); + panel.addChild(scrollable, settings -> settings.align(0.0F, 0.0F)); + panel.addChild(new PanelWidget(panelWidth, height, false, true), settings -> settings.align(0.0F, 0.0F)); + panel.arrangeElements(); + return panel; + } + + private int naturalScrollableTextBoxHeight(final Text text, final int width) { + final int panelWidth = Math.max(1, width - AbstractScrollArea.SCROLLBAR_WIDTH); + final int paddingX = clamp((panelWidth - 1) / 2, 0, TEXT_PADDING_X); + final int textWidth = Math.max(1, panelWidth - paddingX * 2); + return Math.max(1, textWidget(text, textWidth).getHeight() + (TEXT_PADDING_Y + 1) * 2); + } + + private int textPaddingY(final int height) { + final int spaceAroundOneLine = Math.max(0, height - font.lineHeight - 2); + return Math.min(TEXT_PADDING_Y, spaceAroundOneLine / 2); + } + + private MultiLineTextWidget textWidget(final Text text, final int width) { + final var widget = new MultiLineTextWidget(component(text), font).setMaxWidth(Math.max(1, width)); + widget.setComponentClickHandler(style -> { + if (style.getClickEvent() != null) { + defaultHandleClickEvent(style.getClickEvent(), minecraft, this); + } + }); + widget.active = true; + return widget; + } + + private net.minecraft.client.gui.components.Checkbox wrap(final Checkbox checkbox, final int width) { + final var widget = net.minecraft.client.gui.components.Checkbox.builder(component(checkbox.label()), font) + .maxWidth(Math.max(1, width)) + .onValueChange((self, value) -> { + checkbox.selected(value); + checkbox.onStateChange(screen); + screenExecutor.execute(() -> { + saveScrollAmounts(); + rebuildWidgets(); + }); + }) + .selected(checkbox.enabled() && checkbox.selected()) + .build(); + widget.active = checkbox.enabled(); + widget.setAlpha(checkbox.enabled() ? 1.0F : DISABLED_ALPHA); + return widget; + } + + private net.minecraft.client.gui.components.Button wrap(final Button button, final int width, final int height) { + final var widget = net.minecraft.client.gui.components.Button.builder(component(button.label()), ignored -> button.onClick(screen)) + .size(Math.max(1, width), Math.max(1, height)) + .build(); + widget.active = button.enabled(); + return widget; + } + + private ScrollableLayout wrapScrollable(final Object element, final Layout content, final int width, final int height) { + final var scrollable = new ScrollableLayout(minecraft, content, Math.max(1, height)); + scrollable.setMinWidth(scrollContentWidth(width)); + scrollable.setMinHeight(Math.max(1, height)); + scrollable.setMaxHeight(Math.max(1, height)); + scrollable.arrangeElements(); + scrollable.visitWidgets(widget -> { + if (widget instanceof final AbstractScrollArea scrollArea) { + scrollAreas.put(element, scrollArea); + scrollAreasByPriority.add(0, scrollArea); + } + }); + return scrollable; + } + + private FrameLayout frame(final int width, final int height, final LayoutElement child, final float alignX, final float alignY) { + final var frame = new FrameLayout(Math.max(1, width), Math.max(1, height)); + frame.addChild(child, settings -> settings.align(alignX, alignY)); + frame.arrangeElements(); + return frame; + } + + private int[] distribute(final int available, final List> elements, final Division.Orientation orientation) { + final int[] result = new int[elements.size()]; + if (elements.isEmpty()) return result; + + long totalWeight = 0; + for (final var element : elements) { + totalWeight += Math.max(0, + orientation == Division.Orientation.HORIZONTAL ? element.width() : element.height()); + } + final boolean useEqualWeights = totalWeight == 0; + if (useEqualWeights) totalWeight = elements.size(); + + int assigned = 0; + long cumulativeWeight = 0; + for (int index = 0; index < elements.size(); index++) { + final int weight = Math.max(0, + orientation == Division.Orientation.HORIZONTAL + ? elements.get(index).width() + : elements.get(index).height()); + cumulativeWeight += useEqualWeights ? 1 : weight; + final int cumulativeSize = (int) (available * cumulativeWeight / totalWeight); + result[index] = Math.max(0, cumulativeSize - assigned); + assigned = cumulativeSize; + } + return result; + } + + private int percentage(final int available, final int percentage) { + return Math.max(1, (int) ((long) Math.max(0, available) * Math.max(0, percentage) / 100)); + } + + private int scrollContentWidth(final int width) { + return Math.max(1, width - SCROLLBAR_RESERVE); + } + + private int topSeparator() { + return clamp(height / 3, 1, HEADER_HEIGHT); + } + + private int bottomSeparator() { + return Math.max(topSeparator() + 1, height - clamp(height / 3, 1, FOOTER_HEIGHT)); + } + + public static int clamp(final long value, final int min, final int max) { + if (min > max) throw new IllegalArgumentException(min + " > " + max); + return (int) Math.min(max, Math.max(value, min)); + } + + private static final class PanelWidget extends AbstractWidget { + private final boolean background; + private final boolean border; + + private PanelWidget(final int width, final int height) { + this(width, height, true, true); + } + + private PanelWidget(final int width, final int height, final boolean background, final boolean border) { + super(0, 0, Math.max(1, width), Math.max(1, height), Component.empty()); + this.background = background; + this.border = border; + active = false; + } + + @Override + protected void extractWidgetRenderState(final GuiGraphicsExtractor graphics, final int mouseX, final int mouseY, final float a) { + if (background) { + graphics.fill(getX() + 1, getY() + 1, getRight() - 1, getBottom() - 1, PANEL_BACKGROUND); + } + if (border) graphics.outline(getX(), getY(), getWidth(), getHeight(), PANEL_BORDER); + } + + @Override + protected void updateWidgetNarration(final NarrationElementOutput output) { + } + } + } +} diff --git a/fabric/src/main/java/dev/faststats/fabric/screen/FabricText.java b/fabric/src/main/java/dev/faststats/fabric/screen/FabricText.java new file mode 100644 index 00000000..01a66ed8 --- /dev/null +++ b/fabric/src/main/java/dev/faststats/fabric/screen/FabricText.java @@ -0,0 +1,69 @@ +package dev.faststats.fabric.screen; + +import dev.faststats.screen.Text; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.minecraft.network.chat.Style; + +import java.net.URI; + +public record FabricText(MutableComponent text) implements Text { + @Override + public Text append(final Text text) { + this.text.append(((FabricText) text).text); + return this; + } + + @Override + public Text append(final String string) { + this.text.append(string); + return this; + } + + @Override + public Text append(final String string, final URI url) { + this.text.append(Component.literal(string).withStyle(Style.EMPTY.withClickEvent(new ClickEvent.OpenUrl(url)))); + return this; + } + + @Override + public Text color(final int color) { + this.text.withStyle(Style.EMPTY.withColor(color)); + return this; + } + + @Override + public Text format(final Formatting formatting) { + this.text.withStyle(toFormat(formatting)); + return this; + } + + private ChatFormatting toFormat(final Formatting formatting) { + return switch (formatting) { + case BLACK -> ChatFormatting.BLACK; + case DARK_BLUE -> ChatFormatting.DARK_BLUE; + case DARK_GREEN -> ChatFormatting.DARK_GREEN; + case DARK_AQUA -> ChatFormatting.DARK_AQUA; + case DARK_RED -> ChatFormatting.DARK_RED; + case DARK_PURPLE -> ChatFormatting.DARK_PURPLE; + case GOLD -> ChatFormatting.GOLD; + case GRAY -> ChatFormatting.GRAY; + case DARK_GRAY -> ChatFormatting.DARK_GRAY; + case BLUE -> ChatFormatting.BLUE; + case GREEN -> ChatFormatting.GREEN; + case AQUA -> ChatFormatting.AQUA; + case RED -> ChatFormatting.RED; + case LIGHT_PURPLE -> ChatFormatting.LIGHT_PURPLE; + case YELLOW -> ChatFormatting.YELLOW; + case WHITE -> ChatFormatting.WHITE; + case OBFUSCATED -> ChatFormatting.OBFUSCATED; + case BOLD -> ChatFormatting.BOLD; + case STRIKETHROUGH -> ChatFormatting.STRIKETHROUGH; + case UNDERLINE -> ChatFormatting.UNDERLINE; + case ITALIC -> ChatFormatting.ITALIC; + case RESET -> ChatFormatting.RESET; + }; + } +} diff --git a/fabric/src/main/java/module-info.java b/fabric/src/main/java/module-info.java index 7a85bdcf..124e6106 100644 --- a/fabric/src/main/java/module-info.java +++ b/fabric/src/main/java/module-info.java @@ -1,13 +1,18 @@ +import dev.faststats.fabric.screen.FabricScreenManager; +import dev.faststats.screen.ScreenManager; import org.jspecify.annotations.NullMarked; @NullMarked module dev.faststats.fabric { exports dev.faststats.fabric.compat; + exports dev.faststats.fabric.screen; exports dev.faststats.fabric; requires com.google.gson; requires dev.faststats.config; + requires dev.faststats.onboarding; requires dev.faststats; + requires java.desktop; requires java.logging; requires net.fabricmc.loader; requires org.slf4j; @@ -15,5 +20,7 @@ requires static org.jetbrains.annotations; requires static org.jspecify; + provides ScreenManager with FabricScreenManager; + uses dev.faststats.fabric.compat.CompatibilityLayer; } diff --git a/fabric/src/main/resources/META-INF/services/dev.faststats.screen.ScreenManager b/fabric/src/main/resources/META-INF/services/dev.faststats.screen.ScreenManager new file mode 100644 index 00000000..f2d7ceaf --- /dev/null +++ b/fabric/src/main/resources/META-INF/services/dev.faststats.screen.ScreenManager @@ -0,0 +1 @@ +dev.faststats.fabric.screen.FabricScreenManager diff --git a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java index ae756d8d..f8505a9a 100644 --- a/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java +++ b/hytale/src/main/java/dev/faststats/hytale/HytaleMetricsImpl.java @@ -10,7 +10,7 @@ final class HytaleMetricsImpl extends SimpleMetrics { private final String pluginVersion; - HytaleMetricsImpl(final Factory factory, JavaPlugin plugin) throws IllegalStateException { + HytaleMetricsImpl(final Factory factory, final JavaPlugin plugin) throws IllegalStateException { super(factory); this.pluginVersion = plugin.getManifest().getVersion().toString(); } diff --git a/neoforge/build.gradle.kts b/neoforge/build.gradle.kts index 6d1a0caf..04d45291 100644 --- a/neoforge/build.gradle.kts +++ b/neoforge/build.gradle.kts @@ -59,8 +59,7 @@ configurations.configureEach { } dependencies { - compileOnlyApi(project(":core")) - compileOnly(project(":config")) + compileOnlyApi(project(":onboarding")) compileOnly("net.neoforged:bus:8.0.5") } diff --git a/neoforge/example-mod/build.gradle.kts b/neoforge/example-mod/build.gradle.kts index 5b92927c..abd68a77 100644 --- a/neoforge/example-mod/build.gradle.kts +++ b/neoforge/example-mod/build.gradle.kts @@ -12,6 +12,8 @@ configurations.configureEach { } dependencies { + compileOnly(project(":onboarding")) compileOnly(project(":neoforge:versions:26.1-26.2")) + jarJar(project(":onboarding")) jarJar(project(":neoforge:versions:26.1-26.2")) } diff --git a/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java b/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java index bd88b98c..8cbaa299 100644 --- a/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java +++ b/neoforge/src/main/java/dev/faststats/neoforge/NeoForgeContext.java @@ -1,6 +1,7 @@ package dev.faststats.neoforge; import dev.faststats.Metrics; +import dev.faststats.FastStatsRegistry; import dev.faststats.SimpleContext; import dev.faststats.SimpleMetrics; import dev.faststats.Token; @@ -17,6 +18,7 @@ import net.neoforged.neoforgespi.language.IModInfo; import org.jetbrains.annotations.Contract; +import java.nio.file.Path; import java.util.ServiceLoader; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; @@ -41,16 +43,15 @@ public final class NeoForgeContext extends SimpleContext { private final IModInfo mod; private NeoForgeContext(final Factory factory, final LoggerFactory loggerFactory, final String modId, @Token final String token) { - super(factory, loggerFactory, SimpleConfig.read(FMLPaths.CONFIGDIR.get() - .resolve("faststats").resolve("config.properties"), loggerFactory - ), "neoforge", token); + super(factory, loggerFactory, SimpleConfig.read(configPath(), loggerFactory), "neoforge", token); this.mod = ModList.get().getModContainerById(modId).map(ModContainer::getModInfo).orElseThrow(() -> { return new IllegalArgumentException("Mod not found: " + modId); }); this.compatibilityLayer = ServiceLoader.load(CompatibilityLayer.class) .findFirst() .orElseThrow(() -> new IllegalStateException("No NeoForge compatibility layer found")); - initializeServices(factory); + initializeManagedServices(factory); + FastStatsRegistry.instance().register(this); switch (compatibilityLayer.getEnvironment()) { case CLIENT -> ready(); case SERVER -> { @@ -91,12 +92,17 @@ protected void scheduleAtFixedRate(final Runnable task, final long initialDelay, @Override public void shutdown() { + FastStatsRegistry.instance().unregister(this); super.shutdown(); tasks.forEach(task -> task.cancel(true)); tasks.clear(); executor.shutdown(); } + private static Path configPath() { + return FMLPaths.CONFIGDIR.get().resolve("faststats").resolve("config.properties"); + } + public static final class Factory extends SimpleContext.Factory { private final String modId; private final @Token String token; diff --git a/onboarding/build.gradle.kts b/onboarding/build.gradle.kts new file mode 100644 index 00000000..ba059bb4 --- /dev/null +++ b/onboarding/build.gradle.kts @@ -0,0 +1,13 @@ +extra.set("moduleName", "dev.faststats.onboarding") + +java.toolchain.languageVersion = JavaLanguageVersion.of(25) + +tasks.compileJava { + options.release.set(17) +} + +dependencies { + api(project(":config")) + api(project(":core")) + compileOnlyApi("org.jspecify:jspecify:1.0.0") +} diff --git a/onboarding/src/main/java/dev/faststats/screen/Button.java b/onboarding/src/main/java/dev/faststats/screen/Button.java new file mode 100644 index 00000000..ba22207b --- /dev/null +++ b/onboarding/src/main/java/dev/faststats/screen/Button.java @@ -0,0 +1,19 @@ +package dev.faststats.screen; + +import java.util.function.BiConsumer; + +public sealed interface Button extends Element