diff --git a/.gitignore b/.gitignore index 3690a43e6..7f93bd1e2 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ build .gradle !.gitkeep .kotlin + +.DS_Store diff --git a/build.gradle.kts b/build.gradle.kts index c515be924..780a919a4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -154,10 +154,10 @@ val commonManifest = manifests.manifest { mixin("controlify.mixins.json") mixin("controlify-compat.yacl.mixins.json") - ifPresent("deps.iris") { mixin("controlify-compat.iris.mixins.json") } - ifPresent("deps.sodium") { mixin("controlify-compat.sodium.mixins.json") } - ifPresent("deps.reeses-sodium-options") { mixin("controlify-compat.reeses-sodium-options.mixins.json") } - ifPresent("deps.svc") { mixin("controlify-compat.svc.mixins.json") } + ifPresent("dep.iris") { mixin("controlify-compat.iris.mixins.json") } + ifPresent("dep.sodium") { mixin("controlify-compat.sodium.mixins.json") } + ifPresent("dep.rso") { mixin("controlify-compat.reeses-sodium-options.mixins.json") } + ifPresent("dep.svc") { mixin("controlify-compat.simple-voice-chat.mixins.json") } mixin("controlify-compat.rrls.mixins.json") dependency("minecraft", REQUIRED, minecraftRange) @@ -169,7 +169,13 @@ manifests { "controlify:default_config", "controlify:controller_type", "controlify:default_binds", - "controlify:trigger_effect", + "controlify:contextual/domain/in_game", + "controlify:contextual/domain/container", + "controlify:contextual/ruleset/guide", + "controlify:contextual/ruleset/trigger_effect", + "controlify:activators", + "controlify:controller_mapping", + "controlify:rumble_sources", "controlify:radial_icons", ) )) @@ -177,10 +183,10 @@ manifests { fabricModJson(sourceSets.fabric.get()) { from(commonManifest) - entrypoint("modmenu", "dev.isxander.controlify.fabric.compatibility.ModMenuIntegration") - entrypoint("main", "dev.isxander.controlify.fabric.ControlifyBootstrap") - entrypoint("client", "dev.isxander.controlify.fabric.ControlifyBootstrap") - entrypoint("server", "dev.isxander.controlify.fabric.ControlifyBootstrap") + entrypoint("modmenu", "dev.isxander.controlify.fabric.compat.ModMenuIntegration") + entrypoint("main", "dev.isxander.controlify.fabric.impl.ControlifyBootstrap") + entrypoint("client", "dev.isxander.controlify.fabric.impl.ControlifyBootstrap") + entrypoint("server", "dev.isxander.controlify.fabric.impl.ControlifyBootstrap") dependency("fabricloader", REQUIRED, "[0.19,)") mixin("controlify-platform.fabric.mixins.json") @@ -281,6 +287,18 @@ if (includeNatives) { } } +// Run configs + +// Fabric +loom.runConfigs.configureEach { + runDirectory = layout.projectDirectory.dir("run") +} + +// NeoForge +runs.configureEach { + workingDirectory = layout.projectDirectory.dir("run") +} + /// Testing fabricApi { diff --git a/gradle.properties b/gradle.properties index f357c4d94..f2513bb10 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -mod.version=3.5.3 +mod.version=4.0.0-test.2 mod.id=controlify mod.name=Controlify mod.description=The most advanced controller mod for Minecraft. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56ccb5774..b7a20f463 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,14 @@ [versions] fabric_loader = "0.19.3" sdl_natives = "release-3.4.12" -sdl_java = "release-3.4.14-7" +sdl_java = "release-3.4.14-8" steamdeck4j = "1.1.7" quilt_parsers = "0.3.0" modstitch_manifests = "0.1.5" modstitch_multiloader = "0.1.9" modstitch_modrepos = "0.1.2" -fabric_loom = "1.17.16" +fabric_loom = "1.19.3" neoforged_gradle = "7.1.39" spotless = "8.6.0" undercouch_download = "5.6.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 683f1763c..fd35d1463 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Jul 21 16:53:14 BST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 96f0aab63..27a272756 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,7 +15,10 @@ pluginManagement { } } exclusiveContent { - forRepository { maven("https://maven.kikugie.dev/releases") } + forRepositories( + maven("https://maven.kikugie.dev/releases"), + maven("https://maven.kikugie.dev/snapshots") + ) filter { includeGroupAndSubgroups("dev.kikugie") } } exclusiveContent { @@ -28,7 +31,7 @@ pluginManagement { } plugins { - id("dev.kikugie.stonecutter") version "0.9.7" + id("dev.kikugie.stonecutter") version "0.10-beta.1" id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } diff --git a/src/fabric/java/dev/isxander/controlify/fabric/compatibility/FabricCompatMixinPlatform.java b/src/fabric/java/dev/isxander/controlify/fabric/compat/FabricCompatMixinPlatform.java similarity index 75% rename from src/fabric/java/dev/isxander/controlify/fabric/compatibility/FabricCompatMixinPlatform.java rename to src/fabric/java/dev/isxander/controlify/fabric/compat/FabricCompatMixinPlatform.java index 9a7096abf..cf1b96379 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/compatibility/FabricCompatMixinPlatform.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/compat/FabricCompatMixinPlatform.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.compatibility; +package dev.isxander.controlify.fabric.compat; -import dev.isxander.controlify.compatibility.CompatMixinPlatform; +import dev.isxander.controlify.compat.CompatMixinPlatform; import net.fabricmc.loader.api.FabricLoader; public final class FabricCompatMixinPlatform implements CompatMixinPlatform { diff --git a/src/fabric/java/dev/isxander/controlify/fabric/compatibility/ModMenuIntegration.java b/src/fabric/java/dev/isxander/controlify/fabric/compat/ModMenuIntegration.java similarity index 77% rename from src/fabric/java/dev/isxander/controlify/fabric/compatibility/ModMenuIntegration.java rename to src/fabric/java/dev/isxander/controlify/fabric/compat/ModMenuIntegration.java index 634aba6e3..aaa266cc9 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/compatibility/ModMenuIntegration.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/compat/ModMenuIntegration.java @@ -4,13 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.compatibility; +package dev.isxander.controlify.fabric.compat; //? if mod_menu { import com.terraformersmc.modmenu.api.ConfigScreenFactory; import com.terraformersmc.modmenu.api.ModMenuApi; -import dev.isxander.controlify.gui.screen.ControlifySettingsScreen; +import dev.isxander.controlify.impl.gui.screen.ControlifySettingsScreen; public class ModMenuIntegration implements ModMenuApi { @Override diff --git a/src/fabric/java/dev/isxander/controlify/fabric/ControlifyBootstrap.java b/src/fabric/java/dev/isxander/controlify/fabric/impl/ControlifyBootstrap.java similarity index 57% rename from src/fabric/java/dev/isxander/controlify/fabric/ControlifyBootstrap.java rename to src/fabric/java/dev/isxander/controlify/fabric/impl/ControlifyBootstrap.java index 896effd42..3aa16b45a 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/ControlifyBootstrap.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/impl/ControlifyBootstrap.java @@ -4,14 +4,14 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric; +package dev.isxander.controlify.fabric.impl; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.fabric.platform.client.FabricPlatformClientImpl; -import dev.isxander.controlify.fabric.platform.FabricPlatformMainImpl; -import dev.isxander.controlify.platform.client.PlatformClientUtil; -import dev.isxander.controlify.platform.main.PlatformMainUtil; -import dev.isxander.controlify.server.ControlifyServer; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.fabric.impl.platform.client.FabricPlatformClientImpl; +import dev.isxander.controlify.fabric.impl.platform.FabricPlatformMainImpl; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.server.ControlifyServer; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.DedicatedServerModInitializer; import net.fabricmc.api.ModInitializer; @@ -20,6 +20,8 @@ public class ControlifyBootstrap implements ClientModInitializer, ModInitializer @Override public void onInitializeClient() { PlatformClientUtil.IMPL = new FabricPlatformClientImpl(); + dev.isxander.controlify.impl.screenop.BuiltinScreenBehaviours.initialize(); + PlatformMainUtil.invokeControlifyEntrypoints(); Controlify.instance().preInitialiseControlify(); } diff --git a/src/fabric/java/dev/isxander/controlify/fabric/platform/FabricBackedEventHandler.java b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/FabricBackedEventHandler.java similarity index 86% rename from src/fabric/java/dev/isxander/controlify/fabric/platform/FabricBackedEventHandler.java rename to src/fabric/java/dev/isxander/controlify/fabric/impl/platform/FabricBackedEventHandler.java index a90f48a7e..84eb767ae 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/platform/FabricBackedEventHandler.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/FabricBackedEventHandler.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.platform; +package dev.isxander.controlify.fabric.impl.platform; -import dev.isxander.controlify.platform.EventHandler; +import dev.isxander.controlify.impl.platform.EventHandler; import net.fabricmc.fabric.api.event.EventFactory; public class FabricBackedEventHandler implements EventHandler { diff --git a/src/fabric/java/dev/isxander/controlify/fabric/platform/FabricPlatformMainImpl.java b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/FabricPlatformMainImpl.java similarity index 69% rename from src/fabric/java/dev/isxander/controlify/fabric/platform/FabricPlatformMainImpl.java rename to src/fabric/java/dev/isxander/controlify/fabric/impl/platform/FabricPlatformMainImpl.java index 3b20c45cc..cf39a1435 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/platform/FabricPlatformMainImpl.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/FabricPlatformMainImpl.java @@ -4,18 +4,19 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.platform; - -import dev.isxander.controlify.api.entrypoint.ControlifyEntrypoint; -import dev.isxander.controlify.fabric.platform.network.C2SNetworkApiFabric; -import dev.isxander.controlify.fabric.platform.network.S2CNetworkApiFabric; -import dev.isxander.controlify.platform.Environment; -import dev.isxander.controlify.platform.main.PlatformMainUtilImpl; -import dev.isxander.controlify.platform.main.events.CommandRegistrationCallbackEvent; -import dev.isxander.controlify.platform.main.events.HandshakeCompletionEvent; -import dev.isxander.controlify.platform.main.events.PlayerJoinedEvent; -import dev.isxander.controlify.platform.network.C2SNetworkApi; -import dev.isxander.controlify.platform.network.S2CNetworkApi; +package dev.isxander.controlify.fabric.impl.platform; + +import dev.isxander.controlify.api.ControlifyEntrypoint; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.fabric.impl.platform.network.C2SNetworkApiFabric; +import dev.isxander.controlify.fabric.impl.platform.network.S2CNetworkApiFabric; +import dev.isxander.controlify.impl.platform.Environment; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtilImpl; +import dev.isxander.controlify.impl.platform.main.events.CommandRegistrationCallbackEvent; +import dev.isxander.controlify.impl.platform.main.events.HandshakeCompletionEvent; +import dev.isxander.controlify.impl.platform.main.events.PlayerJoinedEvent; +import dev.isxander.controlify.impl.platform.network.C2SNetworkApi; +import dev.isxander.controlify.impl.platform.network.S2CNetworkApi; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.networking.v1.FriendlyByteBufs; import net.fabricmc.fabric.api.networking.v1.ServerLoginConnectionEvents; @@ -34,6 +35,8 @@ import java.util.Arrays; import java.util.Optional; import java.util.ServiceLoader; +import java.util.HashSet; +import java.util.Set; import java.util.function.Consumer; import java.util.function.Supplier; @@ -58,9 +61,27 @@ public boolean isModLoaded(String... modIds) { @Override public void applyToControlifyEntrypoint(Consumer entrypointConsumer) { - // Use both Fabric's entrypoint system and ServiceLoader to maximize compatibility - FabricLoader.getInstance().getEntrypoints("controlify", ControlifyEntrypoint.class).forEach(entrypointConsumer); - ServiceLoader.load(ControlifyEntrypoint.class).forEach(entrypointConsumer); + // Use both discovery mechanisms, without constructing duplicate service providers. + Set> loaded = new HashSet<>(); + FabricLoader.getInstance().getEntrypointContainers("controlify", ControlifyEntrypoint.class).forEach(container -> { + try { + var entrypoint = container.getEntrypoint(); + if (loaded.add(entrypoint.getClass())) { + entrypointConsumer.accept(entrypoint); + } + } catch (Throwable throwable) { + CUtil.LOGGER.error("Failed to load Controlify addon from mod: {}", container.getProvider().getMetadata().getId(), throwable); + } + }); + ServiceLoader.load(ControlifyEntrypoint.class).stream().forEach(provider -> { + try { + if (loaded.add(provider.type())) { + entrypointConsumer.accept(provider.get()); + } + } catch (Throwable throwable) { + CUtil.LOGGER.error("Failed to load Controlify addon provider: {}", provider.type().getName(), throwable); + } + }); } @Override diff --git a/src/fabric/java/dev/isxander/controlify/fabric/platform/client/FAPIApiCreativeTabHelper.java b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/client/FAPIApiCreativeTabHelper.java similarity index 89% rename from src/fabric/java/dev/isxander/controlify/fabric/platform/client/FAPIApiCreativeTabHelper.java rename to src/fabric/java/dev/isxander/controlify/fabric/impl/platform/client/FAPIApiCreativeTabHelper.java index 498c09a5e..5ab1973b7 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/platform/client/FAPIApiCreativeTabHelper.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/client/FAPIApiCreativeTabHelper.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.platform.client; +package dev.isxander.controlify.fabric.impl.platform.client; -import dev.isxander.controlify.platform.client.CreativeTabHelper; +import dev.isxander.controlify.impl.platform.client.CreativeTabHelper; import net.fabricmc.fabric.api.client.creativetab.v1.FabricCreativeModeInventoryScreen; import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; import net.minecraft.world.item.CreativeModeTab; diff --git a/src/fabric/java/dev/isxander/controlify/fabric/platform/client/FabricPlatformClientImpl.java b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/client/FabricPlatformClientImpl.java similarity index 88% rename from src/fabric/java/dev/isxander/controlify/fabric/platform/client/FabricPlatformClientImpl.java rename to src/fabric/java/dev/isxander/controlify/fabric/impl/platform/client/FabricPlatformClientImpl.java index abe5b8a50..45764d668 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/platform/client/FabricPlatformClientImpl.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/client/FabricPlatformClientImpl.java @@ -4,16 +4,16 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.platform.client; - -import dev.isxander.controlify.platform.client.CreativeTabHelper; -import dev.isxander.controlify.platform.client.HudRenderLayer; -import dev.isxander.controlify.platform.client.PlatformClientUtilImpl; -import dev.isxander.controlify.platform.client.events.DisconnectedEvent; -import dev.isxander.controlify.platform.client.events.LifecycleEvent; -import dev.isxander.controlify.platform.client.events.ScreenRenderEvent; -import dev.isxander.controlify.platform.client.events.TickEvent; -import dev.isxander.controlify.platform.client.resource.ControlifyReloadListener; +package dev.isxander.controlify.fabric.impl.platform.client; + +import dev.isxander.controlify.impl.platform.client.CreativeTabHelper; +import dev.isxander.controlify.impl.platform.client.HudRenderLayer; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtilImpl; +import dev.isxander.controlify.impl.platform.client.events.DisconnectedEvent; +import dev.isxander.controlify.impl.platform.client.events.LifecycleEvent; +import dev.isxander.controlify.impl.platform.client.events.ScreenRenderEvent; +import dev.isxander.controlify.impl.platform.client.events.TickEvent; +import dev.isxander.controlify.impl.platform.client.resource.ControlifyReloadListener; import dev.isxander.controlify.fabric.mixins.KeyBindingRegistryImplAccessor; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; diff --git a/src/fabric/java/dev/isxander/controlify/fabric/platform/network/C2SNetworkApiFabric.java b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/C2SNetworkApiFabric.java similarity index 93% rename from src/fabric/java/dev/isxander/controlify/fabric/platform/network/C2SNetworkApiFabric.java rename to src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/C2SNetworkApiFabric.java index 9faf70398..cbfbbd397 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/platform/network/C2SNetworkApiFabric.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/C2SNetworkApiFabric.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.platform.network; +package dev.isxander.controlify.fabric.impl.platform.network; -import dev.isxander.controlify.platform.network.C2SNetworkApi; +import dev.isxander.controlify.impl.platform.network.C2SNetworkApi; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; diff --git a/src/fabric/java/dev/isxander/controlify/fabric/platform/network/FabricPacketWrapper.java b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/FabricPacketWrapper.java similarity index 95% rename from src/fabric/java/dev/isxander/controlify/fabric/platform/network/FabricPacketWrapper.java rename to src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/FabricPacketWrapper.java index e19e300d7..2bde4e2f3 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/platform/network/FabricPacketWrapper.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/FabricPacketWrapper.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.platform.network; +package dev.isxander.controlify.fabric.impl.platform.network; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.minecraft.network.FriendlyByteBuf; diff --git a/src/fabric/java/dev/isxander/controlify/fabric/platform/network/S2CNetworkApiFabric.java b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/S2CNetworkApiFabric.java similarity index 92% rename from src/fabric/java/dev/isxander/controlify/fabric/platform/network/S2CNetworkApiFabric.java rename to src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/S2CNetworkApiFabric.java index 7c4f16cb9..0409070fe 100644 --- a/src/fabric/java/dev/isxander/controlify/fabric/platform/network/S2CNetworkApiFabric.java +++ b/src/fabric/java/dev/isxander/controlify/fabric/impl/platform/network/S2CNetworkApiFabric.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fabric.platform.network; +package dev.isxander.controlify.fabric.impl.platform.network; -import dev.isxander.controlify.platform.network.S2CNetworkApi; +import dev.isxander.controlify.impl.platform.network.S2CNetworkApi; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java index aacae76ae..62d3d03b0 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/CTestUtil.java @@ -6,16 +6,9 @@ */ package dev.isxander.controlify.gametest.framework; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.config.settings.GlobalSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.gyro.GyroState; -import dev.isxander.controlify.controllermanager.ControllerManager; -import dev.isxander.controlify.gametest.mixin.ControlifySettingsAccessor; import dev.isxander.controlify.gametest.mixin.ToastInstanceAccessor; import dev.isxander.controlify.gametest.mixin.ToastManagerAccessor; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; import net.fabricmc.fabric.api.resource.v1.ResourceLoader; import net.fabricmc.fabric.api.resource.v1.pack.PackActivationType; @@ -36,10 +29,8 @@ import net.minecraft.world.entity.EntityType; import org.jetbrains.annotations.Nullable; -import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; -import java.util.stream.Collectors; import java.util.stream.Stream; @SuppressWarnings("UnstableApiUsage") @@ -155,8 +146,8 @@ public static void clearToasts(ClientGameTestContext context) { //? if >=26.2 { minecraft.gui.toastManager().clear(); //?} else { - /*minecraft.getToastManager().clear(); - *///?} + //minecraft.getToastManager().clear(); + //?} }); } @@ -164,8 +155,8 @@ public static boolean isToastPresent(Minecraft minecraft, String translationKey) //? if >=26.2 { var toastManager = minecraft.gui.toastManager(); //?} else { - /*var toastManager = minecraft.getToastManager(); - *///?} + //var toastManager = minecraft.getToastManager(); + //?} String text = Component.translatable(translationKey).getString(); diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestUnitWorldContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestUnitWorldContext.java index 1f6a859d5..485c690a6 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/TestUnitWorldContext.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/TestUnitWorldContext.java @@ -46,8 +46,8 @@ public TestUnitWorldContext(ClientGameTestContext context) { //? if >=26.2 { this.world.getConnection().waitForChunksDownload(); //?} else { - /*this.world.getClientLevel().waitForChunksDownload(); - *///?} + //this.world.getClientLevel().waitForChunksDownload(); + //?} this.level = this.world.getServer().computeOnServer(server -> CTestUtil.getPrincipalPlayer(server).level()); this.floorY = this.world.getServer().computeOnServer(server -> { var player = CTestUtil.getPrincipalPlayer(server); @@ -74,8 +74,8 @@ public TestRegionContext allocateRegion(Vec3i size) { //? if >=26.2 { getWorld().getConnection().waitForChunksRender(); //?} else { - /*getWorld().getClientLevel().waitForChunksRender(); - *///?} + //getWorld().getClientLevel().waitForChunksRender(); + //?} return region; } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/ControlifyGameTestContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/ControlifyGameTestContext.java index d748d7e31..66c46608c 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/ControlifyGameTestContext.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/ControlifyGameTestContext.java @@ -6,17 +6,17 @@ */ package dev.isxander.controlify.gametest.framework.controller; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; -import dev.isxander.controlify.config.settings.GlobalSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.gyro.GyroState; -import dev.isxander.controlify.controllermanager.ControllerManager; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.api.InputMode; +import dev.isxander.controlify.impl.config.settings.GlobalSettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controllermanager.ControllerManager; import dev.isxander.controlify.gametest.mixin.ControlifySettingsAccessor; -import dev.isxander.controlify.gametest.mixin.SDLControllerManagerAccessor; +import dev.isxander.controlify.gametest.mixin.SDLControllerBackendAccessor; import dev.isxander.sdl.Sdl; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import org.joml.Vector3f; import java.util.List; import java.util.stream.Collectors; @@ -28,7 +28,7 @@ public class ControlifyGameTestContext { public ControlifyGameTestContext(ClientGameTestContext context) { this.context = context; - this.sdl = ((SDLControllerManagerAccessor) Controlify.instance().getControllerManager().orElseThrow()).controlify_test$getSdl(); + this.sdl = ((SDLControllerBackendAccessor) Controlify.instance().getControllerCoordinator().orElseThrow().backend()).controlify_test$getSdl(); } public VirtualControllerBuilder virtualControllerBuilder() { @@ -63,29 +63,40 @@ public void resetSettings() { settings.putProfileSettings(activeProfileIndex, profileDefaults); var connectedControllers = controlify.getControllerManager() - .map(ControllerManager::getConnectedControllers) + .map(ControllerManager::getAllControllers) .orElseGet(List::of); var connectedUids = connectedControllers.stream() - .map(ControllerEntity::uid) + .map(ControllerImpl::uid) .collect(Collectors.toSet()); // Keep the existing objects for connected devices because input // components cache their DeviceSettings reference when attached. var deviceSettings = settingsAccessor.controlify_test$getDeviceSettings(); deviceSettings.keySet().retainAll(connectedUids); - for (ControllerEntity controller : connectedControllers) { + for (ControllerImpl controller : connectedControllers) { var device = settings.getOrCreateDeviceSettings(controller.uid()); device.name = controller.name(); device.lastSeen = System.currentTimeMillis(); - device.controllerType = controller.info().type().namespace(); - device.gyroCalibration.offset = new GyroState(); + device.controllerType = controller.info().type().id(); + device.gyroCalibration.offset = new Vector3f(); device.mapping = null; controller.setSettings(profileDefaults); + controller.input().reloadActions(); + bindDefaultInputs(controller); } controlify.applyControllerSelection(false); config.markDirty(); }); } + + static void bindDefaultInputs(ControllerImpl controller) { + // Each virtual device test starts with its default layout, without saved binding overrides. + controller.input().getAllActions().forEach(action -> { + action.setBindings(action.defaultBindings()); + controller.settings().input.bindings.bindings.remove(action.spec().id()); + }); + } + } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/DualsenseEffectDriver.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/DualsenseEffectDriver.java index b50254d74..49cbc4e34 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/DualsenseEffectDriver.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/DualsenseEffectDriver.java @@ -6,7 +6,7 @@ */ package dev.isxander.controlify.gametest.framework.controller; -import dev.isxander.controlify.driver.dualsense.DualsenseEffectsState; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseEffectsState; import java.lang.foreign.MemorySegment; import java.nio.ByteBuffer; @@ -33,7 +33,7 @@ public DualsenseEffectsState receiveEffect(ByteBuffer effect) { if (isEnabled(command.ucEnableBits1, DualsenseEffectsState.EnableBitFlags1.ALLOW_RIGHT_TRIGGER_FFB)) { this.firmwareState.rgucRightTriggerEffect = command.rgucRightTriggerEffect; } - if (isEnabled(command.ucEnableBits2, DualsenseEffectsState.EnableBitFlags2.ALLOW_MUTE_LIGHT)) { + if (isEnabled(command.ucEnableBits2, DualsenseEffectsState.EnableBitFlags2.ALLOW_MIC_LIGHT_MODE)) { this.firmwareState.ucMicLightMode = command.ucMicLightMode; } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/TestControllerContext.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/TestControllerContext.java index 6ebef7144..4ce2d4df5 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/TestControllerContext.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/TestControllerContext.java @@ -6,11 +6,11 @@ */ package dev.isxander.controlify.gametest.framework.controller; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controllermanager.SDLControllerManager; -import dev.isxander.controlify.driver.sdl.SDLException; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controllermanager.SDLUniqueControllerID; +import dev.isxander.controlify.impl.driver.sdl.SDLException; +import dev.isxander.controlify.impl.utils.CUtil; import dev.isxander.sdl.Sdl; import dev.isxander.sdl.SdlGamepad; import dev.isxander.sdl.SdlJoystickHandle; @@ -45,10 +45,10 @@ public TestControllerContext( this.state = state; } - public ControllerEntity getControllerEntity() { + public ControllerImpl getControllerImpl() { return Controlify.instance().getControllerManager().orElseThrow() - .getConnectedControllers().stream() - .filter(c -> c.info().ucid().equals(new SDLControllerManager.SDLUniqueControllerID(this.joystickId))) + .getAllControllers().stream() + .filter(c -> c.info().ucid().equals(new SDLUniqueControllerID(this.joystickId))) .findAny() .orElseThrow(); } @@ -91,13 +91,19 @@ public String describeButton(int button) { } public void holdAxis(int axis, float state) { + // SDL's raw joystick triggers span [-1, 1], while tests use [0, 1]. + if (axis == SdlGamepad.SDL_GAMEPAD_AXIS_LEFT_TRIGGER || axis == SdlGamepad.SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) { + state = state * 2f - 1f; + } short stateShort = CUtil.mapFloatToShort(state); - this.sdl.joystick().SDL_SetJoystickVirtualAxis( + if (!this.sdl.joystick().SDL_SetJoystickVirtualAxis( this.joystickHandle, this.inputs.joystickAxis(axis), stateShort - ); + )) { + throw SDLException.useSDLError(this.sdl, "Failed to set virtual axis " + axis + " to " + state); + } } public void releaseAxis(int axis) { @@ -108,6 +114,13 @@ public void tapAxis(int axis, float state) { this.holdAxis(axis, state); context.waitTick(); this.releaseAxis(axis); + context.waitTick(); + } + + public void releaseAllInputs() { + this.inputs.buttons().forEach(this::releaseButton); + this.inputs.axes().forEach(this::releaseAxis); + this.context.waitTick(); } public void sendSensorData(int sensorType, float... data) { diff --git a/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/VirtualControllerBuilder.java b/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/VirtualControllerBuilder.java index c62a96d10..c17088e09 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/VirtualControllerBuilder.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/framework/controller/VirtualControllerBuilder.java @@ -6,7 +6,7 @@ */ package dev.isxander.controlify.gametest.framework.controller; -import dev.isxander.controlify.driver.sdl.SDLException; +import dev.isxander.controlify.impl.driver.sdl.SDLException; import dev.isxander.controlify.test.framework.SdlVirtualJoystickDescBuilder; import dev.isxander.sdl.*; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; @@ -127,9 +127,11 @@ public TestControllerContext attachWithDriver(@Nullable GamepadEffectDriv throw SDLException.useSDLError(sdl, "Failed to open virtual joystick"); } - // Allow Controlify to poll and add controller - this.context.waitTick(); - - return new TestControllerContext<>(this.context, this.sdl, handle, jid, this.inputs, state); + var controller = new TestControllerContext<>(this.context, this.sdl, handle, jid, this.inputs, state); + // Center sticks and fully release triggers before Controlify receives the device. + // This also lets Controlify poll and add the controller. + controller.releaseAllInputs(); + this.context.runOnClient(_ -> ControlifyGameTestContext.bindDefaultInputs(controller.getControllerImpl())); + return controller; } } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java index 9b627f2ab..ed2a902c3 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/ControlifySettingsAccessor.java @@ -6,10 +6,10 @@ */ package dev.isxander.controlify.gametest.mixin; -import dev.isxander.controlify.config.settings.ControlifySettings; -import dev.isxander.controlify.config.settings.GlobalSettings; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.config.settings.ControlifySettings; +import dev.isxander.controlify.impl.config.settings.GlobalSettings; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; import it.unimi.dsi.fastutil.ints.Int2ObjectSortedMap; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerBackendAccessor.java similarity index 68% rename from src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java rename to src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerBackendAccessor.java index bd8a23aa6..b748b72d3 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerAccessor.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerBackendAccessor.java @@ -6,13 +6,13 @@ */ package dev.isxander.controlify.gametest.mixin; -import dev.isxander.controlify.controllermanager.SDLControllerManager; +import dev.isxander.controlify.impl.controllermanager.SDLControllerBackend; import dev.isxander.sdl.Sdl; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.gen.Accessor; -@Mixin(SDLControllerManager.class) -public interface SDLControllerManagerAccessor { +@Mixin(SDLControllerBackend.class) +public interface SDLControllerBackendAccessor { @Accessor("sdl") Sdl controlify_test$getSdl(); } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerBackendMixin.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerBackendMixin.java new file mode 100644 index 000000000..c9fd27f9e --- /dev/null +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerBackendMixin.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.gametest.mixin; + +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import dev.isxander.controlify.impl.controller.ControllerInfo; +import dev.isxander.controlify.impl.controllermanager.SDLControllerBackend; +import dev.isxander.controlify.impl.controllermanager.SDLUniqueControllerID; +import dev.isxander.sdl.Sdl; +import dev.isxander.sdl.SdlJoystickId; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +@Mixin(SDLControllerBackend.class) +public class SDLControllerBackendMixin { + @Shadow + @Final + private Sdl sdl; + + /// Only load virtual joysticks in test environments + @WrapMethod(method = "createAndAddController(Ldev/isxander/controlify/impl/controller/ControllerInfo;Z)V") + private void preventNonVirtual( + ControllerInfo info, + boolean hotplugged, + Operation original + ) { + SdlJoystickId jid = ((SDLUniqueControllerID) info.ucid()).jid(); + + if (!sdl.joystick().SDL_IsJoystickVirtual(jid)) { + return; + } + + original.call(info, hotplugged); + } +} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java deleted file mode 100644 index fa6ab7d8c..000000000 --- a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLControllerManagerMixin.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.gametest.mixin; - -import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; -import com.llamalad7.mixinextras.injector.wrapoperation.Operation; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controllermanager.SDLControllerManager; -import dev.isxander.controlify.controllermanager.UniqueControllerID; -import dev.isxander.controlify.hid.ControllerHIDInfo; -import dev.isxander.controlify.utils.log.ControlifyLogger; -import dev.isxander.sdl.Sdl; -import dev.isxander.sdl.SdlJoystickId; -import org.spongepowered.asm.mixin.Final; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; - -import java.util.Optional; - -@Mixin(SDLControllerManager.class) -public class SDLControllerManagerMixin { - @Shadow - @Final - private Sdl sdl; - - /// Only load virtual joysticks in test environments - @WrapMethod(method = "createController") - private Optional preventNonVirtual( - UniqueControllerID ucid, - ControllerHIDInfo hidInfo, - ControlifyLogger controllerLogger, - Operation> original - ) { - SdlJoystickId jid = ((SDLControllerManager.SDLUniqueControllerID) ucid).jid(); - - if (!sdl.joystick().SDL_IsJoystickVirtual(jid)) { - controllerLogger.warn("Suppressing creation of controller as the test environment permits only virtual controllers"); - return Optional.empty(); - } - - return original.call(ucid, hidInfo, controllerLogger); - } -} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLEventHandlerAccessor.java b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLEventHandlerAccessor.java index e96a6caec..debc6c03b 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLEventHandlerAccessor.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/mixin/SDLEventHandlerAccessor.java @@ -19,6 +19,6 @@ public interface SDLEventHandlerAccessor { void controlify_test$handleMouseMotionEvent(SDL_Event event); } //?} else { -/*@Mixin(dev.isxander.controlify.utils.DummyMixinTarget.class) +/*@Mixin(dev.isxander.controlify.impl.utils.DummyMixinTarget.class) public interface SDLEventHandlerAccessor { } *///?} diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/AdaptiveTriggerTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/AdaptiveTriggerTests.java index 6d1bbbb1e..19ff44ea1 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/AdaptiveTriggerTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/AdaptiveTriggerTests.java @@ -6,8 +6,8 @@ */ package dev.isxander.controlify.gametest.tests; -import dev.isxander.controlify.driver.dualsense.DualsenseEffectsState; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseEffectsState; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseTriggerEffect; import dev.isxander.controlify.gametest.framework.CTestUtil; import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext; import dev.isxander.controlify.gametest.framework.controller.DualsenseEffectDriver; @@ -56,15 +56,15 @@ public void runTest(ClientGameTestContext context) { //? if >=26.2 { world.getConnection().waitForChunksRender(); //?} else { - /*world.getClientLevel().waitForChunksRender(); - *///?} + //world.getClientLevel().waitForChunksRender(); + //?} setServerHands(world, new ItemStack(Items.DIAMOND_SWORD), new ItemStack(Items.SHIELD)); context.waitTick(); testTriggerEffectsDisabled(context, controller); - controller.getControllerEntity().settings().dualsense.triggerEffects = true; + controller.getControllerImpl().settings().dualsense.triggerEffects = true; context.waitTick(); @@ -76,7 +76,7 @@ public void runTest(ClientGameTestContext context) { testResourcePackPriority(context, world, controller); testServerTagRule(context, world, controller); - controller.getControllerEntity().settings().dualsense.triggerEffects = false; + controller.getControllerImpl().settings().dualsense.triggerEffects = false; context.waitTick(); waitForEffects(context, controller, new DualsenseEffectsState()); } @@ -91,7 +91,7 @@ private static void testTriggerEffectsDisabled( ClientGameTestContext context, TestControllerContext controller ) { - controller.getControllerEntity().settings().dualsense.triggerEffects = false; + controller.getControllerImpl().settings().dualsense.triggerEffects = false; context.waitTick(); waitForEffects(context, controller, new DualsenseEffectsState()); } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ButtonGuideTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ButtonGuideTests.java index 8dbdbf306..914b4acb6 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/ButtonGuideTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ButtonGuideTests.java @@ -7,18 +7,20 @@ package dev.isxander.controlify.gametest.tests; import dev.isxander.controlify.api.contextual.InGameContext; -import dev.isxander.controlify.api.contextual.GuideLocation; -import dev.isxander.controlify.contextual.*; +import dev.isxander.controlify.impl.contextual.GuideLocation; +import dev.isxander.controlify.impl.contextual.*; import dev.isxander.controlify.gametest.framework.CEntityTypes; import dev.isxander.controlify.gametest.framework.CTestUtil; import dev.isxander.controlify.gametest.framework.TestUnitWorldContext; import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext; -import dev.isxander.controlify.gui.guide.GuideInstanceImpl; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.gui.guide.GuideInstanceImpl; +import dev.isxander.controlify.impl.utils.CUtil; +import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; import net.minecraft.commands.arguments.EntityAnchorArgument; import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; import net.minecraft.world.phys.EntityHitResult; import java.util.List; @@ -26,7 +28,14 @@ import java.util.stream.Collectors; @SuppressWarnings({"UnstableApiUsage", "unchecked"}) -public class ButtonGuideTests implements FabricClientGameTest { +public class ButtonGuideTests implements FabricClientGameTest, ClientModInitializer { + private static final Identifier OVERRIDE_PACK = CTestUtil.id("guide_override_test"); + + @Override + public void onInitializeClient() { + CTestUtil.registerBuiltinResourcePack(OVERRIDE_PACK); + } + @Override public void runTest(ClientGameTestContext context) { var controlify = new ControlifyGameTestContext(context); @@ -52,14 +61,14 @@ public void runTest(ClientGameTestContext context) { .orElseThrow(); assertRuleLocation(ruleEngine, "inventory", GuideLocation.LEFT); assertRuleLocation(ruleEngine, "radial_menu", GuideLocation.LEFT); - var inGameContext = InGameContext.create(minecraft, controller.getControllerEntity()); + var inGameContext = InGameContext.create(minecraft, controller.getControllerImpl()); var state = domain.calculateState(inGameContext, ruleEngine.factDependencies()); assertFact(state, "looking_at_entity"); assertFact(state, "looking_at_merchant"); var matchingRules = ruleEngine.evaluate(state); - assertBindingRule(matchingRules, "attack"); - assertBindingRule(matchingRules, "use"); + assertActionRule(matchingRules, "attack"); + assertActionRule(matchingRules, "use"); var guide = (GuideInstanceImpl) domain.createGuideInstance(minecraft.font); guide.update(inGameContext); @@ -68,19 +77,19 @@ public void runTest(ClientGameTestContext context) { } }); - try (var _ = CTestUtil.applyResourcePack(context, CUtil.rl("legacy_console"))) { + try (var _ = CTestUtil.applyResourcePack(context, OVERRIDE_PACK)) { context.runOnClient(minecraft -> { var ruleEngine = RuleSetManager.GUIDE_RULES .getRuleEngine(minecraft.level, CUtil.rl("in_game")) .orElseThrow(); - Set expectedBindings = Set.of("jump", "sneak", "drop", "inventory", "attack", "use"); - Set actualBindings = ruleEngine.rules().stream() - .map(rule -> rule.binding().bindId()) + Set expectedActions = Set.of("jump", "sneak", "drop", "inventory", "attack", "use"); + Set actualActions = ruleEngine.rules().stream() + .map(rule -> rule.action().actionId()) .map(id -> id.getPath()) .collect(Collectors.toSet()); - if (!actualBindings.equals(expectedBindings)) { - throw new AssertionError("Expected the Legacy Console guide rules to replace the defaults. Bindings: " + actualBindings); + if (!actualActions.equals(expectedActions)) { + throw new AssertionError("Expected the test guide rules to replace the defaults. Actions: " + actualActions); } assertRuleLocation(ruleEngine, "jump", GuideLocation.LEFT); @@ -103,28 +112,28 @@ private static void assertFact(ContextualState state, String path) { } } - private static void assertBindingRule( + private static void assertActionRule( List rules, - String bindingPath + String actionPath ) { - var bindingId = CUtil.rl(bindingPath); - if (rules.stream().noneMatch(rule -> rule.binding().bindId().equals(bindingId))) { - throw new AssertionError("Expected a matching guide rule for '" + bindingId + "': " + rules); + var actionId = CUtil.rl(actionPath); + if (rules.stream().noneMatch(rule -> rule.action().actionId().equals(actionId))) { + throw new AssertionError("Expected a matching guide rule for '" + actionId + "': " + rules); } } private static void assertRuleLocation( RuleEngine ruleEngine, - String bindingPath, + String actionPath, GuideLocation expectedLocation ) { - var bindingId = CUtil.rl(bindingPath); + var actionId = CUtil.rl(actionPath); List rules = ruleEngine.rules().stream() - .filter(rule -> rule.binding().bindId().equals(bindingId)) + .filter(rule -> rule.action().actionId().equals(actionId)) .toList(); if (rules.isEmpty() || rules.stream().anyMatch(rule -> rule.location() != expectedLocation)) { throw new AssertionError( - "Expected all guide rules for '" + bindingId + "' to be on " + expectedLocation + ": " + rules + "Expected all guide rules for '" + actionId + "' to be on " + expectedLocation + ": " + rules ); } } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java index 3754daea7..f5414ed50 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/KeyboardLayoutTests.java @@ -8,8 +8,8 @@ import dev.isxander.controlify.gametest.framework.CTestUtil; import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext; -import dev.isxander.controlify.screenop.keyboard.KeyboardWidget; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardWidget; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import dev.isxander.sdl.SdlGamepad; import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; @@ -37,8 +37,8 @@ public void runTest(ClientGameTestContext context) { //? if >=26.2 { world.getConnection().waitForChunksRender(); //?} else { - /*world.getClientLevel().waitForChunksRender(); - *///?} + //world.getClientLevel().waitForChunksRender(); + //?} runLocaleTest(context, controlify); } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java index f640319b7..d63eaa856 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ManyHotplugTests.java @@ -29,7 +29,7 @@ public void runTest(ClientGameTestContext context) { context.waitTicks(3); // finds within ControllerManager if it exists - controller.getControllerEntity(); + controller.getControllerImpl(); } CTestUtil.clearToasts(context); } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/RecipeBookNavigationTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/RecipeBookNavigationTests.java index 4cc635bef..e6dd91e53 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/RecipeBookNavigationTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/RecipeBookNavigationTests.java @@ -6,14 +6,15 @@ */ package dev.isxander.controlify.gametest.tests; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.api.InputMode; import dev.isxander.controlify.gametest.framework.TestUnitWorldContext; import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext; import dev.isxander.controlify.gametest.mixin.RecipeBookPageAccessor; +import dev.isxander.controlify.impl.screenop.ScreenRuntime; import dev.isxander.controlify.mixins.feature.virtualmouse.snapping.RecipeBookComponentAccessor; -import dev.isxander.controlify.screenop.compat.vanilla.RecipeBookScreenProcessor; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.RecipeBookScreenBehaviour; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import dev.isxander.sdl.SdlGamepad; import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; @@ -46,7 +47,7 @@ public void runTest(ClientGameTestContext context) { context.runOnClient(client -> { client.player.getRecipeBook().setOpen(RecipeBookType.CRAFTING, true); client.player.getRecipeBook().setFiltering(RecipeBookType.CRAFTING, false); - Controlify.instance().setCurrentController(controller.getControllerEntity(), true); + Controlify.instance().setCurrentController(controller.getControllerImpl(), true); Controlify.instance().setInputMode(InputMode.CONTROLLER); }); region.runOnServer((level, player) -> { @@ -56,7 +57,7 @@ public void runTest(ClientGameTestContext context) { context.waitForScreen(CraftingScreen.class); var recipeBook = context.computeOnClient(_ -> - ((RecipeBookScreenProcessor.RecipeBookScreenAccessor) MinecraftUtil.getScreen()) + ((RecipeBookScreenBehaviour.RecipeBookScreenAccessor) MinecraftUtil.getScreen()) .controlify$getRecipeBookComponent()); var component = (RecipeBookComponentAccessor) recipeBook; var page = context.computeOnClient(_ -> @@ -72,7 +73,7 @@ public void runTest(ClientGameTestContext context) { || page.controlify_test$getCurrentPage() != 0) { throw new AssertionError("Expected the first recipe category and page, with multiple visible categories"); } - if (!Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled()) { + if (ScreenRuntime.virtualMouse().isEmpty()) { throw new AssertionError("Crafting screen must have virtual mouse enabled for recipe navigation"); } }); diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java index b5628ff39..b81f0b192 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ResetControlifySettingsTests.java @@ -6,12 +6,11 @@ */ package dev.isxander.controlify.gametest.tests; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.gyro.GyroState; -import dev.isxander.controlify.gametest.framework.CTestUtil; +import dev.isxander.controlify.impl.Controlify; import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext; import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import org.joml.Vector3f; /// Ensures that {@link ControlifyGameTestContext#resetSettings()} /// works appropriately, not leaving behind any stale references. @@ -25,11 +24,11 @@ public void runTest(ClientGameTestContext context) { .withXbox() .attach()) { var controlify = Controlify.instance(); - var controllerEntity = controller.getControllerEntity(); + var ControllerImpl = controller.getControllerImpl(); var settings = controlify.config().getSettings(); - var cachedDeviceSettings = settings.getOrCreateDeviceSettings(controllerEntity.uid()); + var cachedDeviceSettings = settings.getOrCreateDeviceSettings(ControllerImpl.uid()); - cachedDeviceSettings.gyroCalibration.offset = new GyroState(1f, 2f, 3f); + cachedDeviceSettings.gyroCalibration.offset = new Vector3f(1f, 2f, 3f); settings.globalSettings().mixedInput = true; controlifyContext.resetSettings(); @@ -37,13 +36,13 @@ public void runTest(ClientGameTestContext context) { if (settings.globalSettings().mixedInput) { throw new AssertionError("Global settings were not reset"); } - if (cachedDeviceSettings != settings.getOrCreateDeviceSettings(controllerEntity.uid())) { + if (cachedDeviceSettings != settings.getOrCreateDeviceSettings(ControllerImpl.uid())) { throw new AssertionError("Connected controller device settings reference was replaced"); } - if (!cachedDeviceSettings.gyroCalibration.offset.equals(new GyroState())) { + if (!cachedDeviceSettings.gyroCalibration.offset.equals(new Vector3f())) { throw new AssertionError("Device settings were not reset"); } - if (controllerEntity.settings() != controlify.config().getActiveProfile()) { + if (ControllerImpl.settings() != controlify.config().getActiveProfile()) { throw new AssertionError("Controller was not rebound to the default active profile"); } } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java index 9b577ea55..4befaac33 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/RumbleTests.java @@ -52,8 +52,8 @@ public void runTest(ClientGameTestContext context) { //? if >=26.2 { world.getWorld().getConnection().waitForChunksRender(); //?} else { - /*world.getWorld().getClientLevel().waitForChunksRender(); - *///?} + //world.getWorld().getClientLevel().waitForChunksRender(); + //?} runTest(context, controlify, controller, world, "Lightning bolt", this::testLightningBoltRumble); runTest(context, controlify, controller, world, "Explosion", this::testExplosionRumble); @@ -324,6 +324,12 @@ private void cleanupTest( TestControllerContext controller, TestUnitWorldContext world ) { + controller.releaseAllInputs(); + context.runOnClient(client -> { + if (client.options.keyAttack.isDown() || client.options.keyUse.isDown()) { + throw new AssertionError("Attack or use remained held after releasing the virtual controller"); + } + }); // Wait for rumble to stop context.waitFor(_ -> !controller.state().hasRumble()); } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/ServerPolicyTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/ServerPolicyTests.java index 231263390..169b48269 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/ServerPolicyTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/ServerPolicyTests.java @@ -6,12 +6,11 @@ */ package dev.isxander.controlify.gametest.tests; -import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.impl.Controlify; import dev.isxander.controlify.gametest.framework.CTestUtil; import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext; -import dev.isxander.controlify.server.ControlifyServer; -import dev.isxander.controlify.server.ControlifyServerConfig; -import dev.isxander.controlify.server.ServerPolicies; +import dev.isxander.controlify.impl.server.ControlifyServer; +import dev.isxander.controlify.impl.server.ControlifyServerConfig; import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; import net.minecraft.server.MinecraftServer; @@ -36,8 +35,8 @@ public void runTest(ClientGameTestContext context) { //? if >=26.2 { connection.waitForChunksDownload(); //?} else { - /*connection.getClientLevel().waitForChunksDownload(); - *///?} + //connection.getClientLevel().waitForChunksDownload(); + //?} assertKeyboardMovement(context, true); } diff --git a/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java index 044a1655e..e10315fe0 100644 --- a/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java +++ b/src/gametest/java/dev/isxander/controlify/gametest/tests/VirtualControllerInputTests.java @@ -6,8 +6,8 @@ */ package dev.isxander.controlify.gametest.tests; -import dev.isxander.controlify.controller.input.GamepadInputs; -import dev.isxander.controlify.controller.input.InputComponent; +import dev.isxander.controlify.impl.input.mapping.GamepadInputs; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; import dev.isxander.controlify.gametest.framework.CTestUtil; import dev.isxander.controlify.gametest.framework.controller.ControlifyGameTestContext; import dev.isxander.controlify.gametest.framework.controller.TestControllerContext; @@ -18,11 +18,14 @@ import net.minecraft.resources.Identifier; import java.util.Map; +import java.util.HashSet; +import java.util.List; +import java.util.Set; //? if >=26.3 { import com.mojang.blaze3d.platform.SDLEventHandler; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.api.InputMode; import dev.isxander.controlify.gametest.mixin.SDLEventHandlerAccessor; import org.lwjgl.sdl.SDL_Event; //?} @@ -58,40 +61,49 @@ public void runTest(ClientGameTestContext context) { try (var controller = controlify.virtualControllerBuilder() .withXbox() .attach()) { - var controllerEntity = controller.getControllerEntity(); - var input = controllerEntity.input().orElseThrow(); - - // Fabric cancels real mouse input during tests. Controlify must not - // change input mode before that cancellation takes effect. - //? if >=26.3 { - context.runOnClient(client -> { - Controlify.instance().setInputMode(InputMode.CONTROLLER); - try (var event = SDL_Event.calloc()) { - var handler = new SDLEventHandler(client, client.getWindow()); - ((SDLEventHandlerAccessor) handler).controlify_test$handleMouseMotionEvent(event); - } - if (Controlify.instance().currentInputMode() != InputMode.CONTROLLER) { - throw new AssertionError("Cancelled SDL mouse input changed Controlify input mode"); + var input = controller.getControllerImpl().input(); + // This test observes physical events without triggering menus or gameplay. + context.runOnClient(_ -> input.getAllActions().forEach(action -> action.setBindings(List.of()))); + Set pressedButtons = new HashSet<>(); + try (var _ = context.computeOnClient(_ -> input.inputPipeline().sourceRawPhysicalSignals().subscribe(signal -> { + if (signal instanceof PhysicalInputSignal.ButtonChanged button) { + if (button.state()) pressedButtons.add(button.input()); + else pressedButtons.remove(button.input()); } - }); - //?} + }))) { - context.waitTick(); + // Fabric cancels real mouse input during tests. Controlify must not + // change input mode before that cancellation takes effect. + //? if >=26.3 { + context.runOnClient(client -> { + Controlify.instance().setInputMode(InputMode.CONTROLLER); + try (var event = SDL_Event.calloc()) { + var handler = new SDLEventHandler(client, client.getWindow()); + ((SDLEventHandlerAccessor) handler).controlify_test$handleMouseMotionEvent(event); + } + if (Controlify.instance().currentInputMode() != InputMode.CONTROLLER) { + throw new AssertionError("Cancelled SDL mouse input changed Controlify input mode"); + } + }); + //?} - for (var expected : XINPUT_BUTTONS.entrySet()) { - controller.holdButton(expected.getKey()); - waitForButton(context, controller, input, expected, true); + context.waitTick(); - for (var other : XINPUT_BUTTONS.values()) { - if (!other.equals(expected.getValue()) && input.rawStateNow().isButtonDown(other)) { - throw new AssertionError( - "Pressed " + expected.getValue() + " but also received " + other - ); + for (var expected : XINPUT_BUTTONS.entrySet()) { + controller.holdButton(expected.getKey()); + waitForButton(context, controller, pressedButtons, expected, true); + + for (var other : XINPUT_BUTTONS.values()) { + if (!other.equals(expected.getValue()) && context.computeOnClient(_ -> pressedButtons.contains(other))) { + throw new AssertionError( + "Pressed " + expected.getValue() + " but also received " + other + ); + } } - } - controller.releaseButton(expected.getKey()); - waitForButton(context, controller, input, expected, false); + controller.releaseButton(expected.getKey()); + waitForButton(context, controller, pressedButtons, expected, false); + } } } @@ -102,15 +114,14 @@ public void runTest(ClientGameTestContext context) { private static void waitForButton( ClientGameTestContext context, TestControllerContext controller, - InputComponent input, + Set pressedButtons, Map.Entry expected, boolean down ) { try { - context.waitFor(_ -> input.rawStateNow().isButtonDown(expected.getValue()) == down, 10); + context.waitFor(_ -> pressedButtons.contains(expected.getValue()) == down, 10); } catch (AssertionError failure) { - var pressed = context.computeOnClient(_ -> input.rawStateNow().getButtons().stream() - .filter(input.rawStateNow()::isButtonDown).toList()); + var pressed = context.computeOnClient(_ -> Set.copyOf(pressedButtons)); throw new AssertionError("Timed out waiting for " + expected.getValue() + " to be " + (down ? "pressed" : "released") + "; Controlify pressed=" + pressed + "; " + controller.describeButton(expected.getKey()), failure); diff --git a/src/gametest/resources/controlify_test.mixins.json b/src/gametest/resources/controlify_test.mixins.json index 008de9104..7e971ad9d 100644 --- a/src/gametest/resources/controlify_test.mixins.json +++ b/src/gametest/resources/controlify_test.mixins.json @@ -14,8 +14,8 @@ }, "mixins": [ "ControlifySettingsAccessor", - "SDLControllerManagerAccessor", - "SDLControllerManagerMixin" + "SDLControllerBackendAccessor", + "SDLControllerBackendMixin" ], "client": [ "RecipeBookPageAccessor", diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json index 469ed2fda..c417bd96c 100644 --- a/src/gametest/resources/fabric.mod.json +++ b/src/gametest/resources/fabric.mod.json @@ -23,6 +23,7 @@ "dev.isxander.controlify.gametest.tests.ButtonGuideTests" ], "client": [ + "dev.isxander.controlify.gametest.tests.ButtonGuideTests", "dev.isxander.controlify.gametest.tests.KeyboardLayoutTests", "dev.isxander.controlify.gametest.tests.AdaptiveTriggerTests" ] diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/contextual/trigger_effect/in_game.json b/src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/controller/contextual/trigger_effect/in_game.json similarity index 100% rename from src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/contextual/trigger_effect/in_game.json rename to src/gametest/resources/resourcepacks/adaptive_trigger_high_priority/assets/controlify/controller/contextual/trigger_effect/in_game.json diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/contextual/trigger_effect/in_game.json b/src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/controller/contextual/trigger_effect/in_game.json similarity index 100% rename from src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/contextual/trigger_effect/in_game.json rename to src/gametest/resources/resourcepacks/adaptive_trigger_low_priority/assets/controlify/controller/contextual/trigger_effect/in_game.json diff --git a/src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/contextual/trigger_effect/in_game.json b/src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/controller/contextual/trigger_effect/in_game.json similarity index 100% rename from src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/contextual/trigger_effect/in_game.json rename to src/gametest/resources/resourcepacks/adaptive_trigger_server_tag/assets/controlify/controller/contextual/trigger_effect/in_game.json diff --git a/src/gametest/resources/resourcepacks/guide_override_test/assets/controlify/controller/contextual/guide/in_game.json b/src/gametest/resources/resourcepacks/guide_override_test/assets/controlify/controller/contextual/guide/in_game.json new file mode 100644 index 000000000..d9e222f52 --- /dev/null +++ b/src/gametest/resources/resourcepacks/guide_override_test/assets/controlify/controller/contextual/guide/in_game.json @@ -0,0 +1,53 @@ +{ + "replace": true, + "rules": [ + { + "for": "controlify:jump", + "where": "left", + "if": true, + "then": { + "text": "jump" + } + }, + { + "for": "controlify:sneak", + "where": "left", + "if": true, + "then": { + "text": "sneak" + } + }, + { + "for": "controlify:drop", + "where": "left", + "if": true, + "then": { + "text": "drop" + } + }, + { + "for": "controlify:inventory", + "where": "right", + "if": true, + "then": { + "text": "inventory" + } + }, + { + "for": "controlify:attack", + "where": "right", + "if": true, + "then": { + "text": "attack" + } + }, + { + "for": "controlify:use", + "where": "right", + "if": true, + "then": { + "text": "use" + } + } + ] +} diff --git a/src/gametest/resources/resourcepacks/guide_override_test/pack.mcmeta b/src/gametest/resources/resourcepacks/guide_override_test/pack.mcmeta new file mode 100644 index 000000000..e4e49a77b --- /dev/null +++ b/src/gametest/resources/resourcepacks/guide_override_test/pack.mcmeta @@ -0,0 +1,9 @@ +{ + "pack": { + "description": "Guide override test pack", + "supported_formats": [34, 10000], + "pack_format": 64, + "min_format": 34, + "max_format": [10000, 0] + } +} diff --git a/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/controller/keyboard_layout/full/en_gb.json similarity index 100% rename from src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_gb.json rename to src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/controller/keyboard_layout/full/en_gb.json diff --git a/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json b/src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/controller/keyboard_layout/full/en_us.json similarity index 100% rename from src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/keyboard_layout/full/en_us.json rename to src/gametest/resources/resourcepacks/keyboard_layout_test/assets/controlify/controller/keyboard_layout/full/en_us.json diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java deleted file mode 100644 index 7b6b59380..000000000 --- a/src/main/java/dev/isxander/controlify/Controlify.java +++ /dev/null @@ -1,804 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify; - -import com.mojang.blaze3d.Blaze3D; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.api.bind.ControlifyBindApi; -import dev.isxander.controlify.api.contextual.ContextualDomainRegistry; -import dev.isxander.controlify.api.entrypoint.InitContext; -import dev.isxander.controlify.api.entrypoint.PreInitContext; -import dev.isxander.controlify.bindings.BindContext; -import dev.isxander.controlify.bindings.ControlifyBindApiImpl; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.bindings.RadialIconManager; -import dev.isxander.controlify.bindings.defaults.DefaultBindManager; -import dev.isxander.controlify.compatibility.ControlifyCompat; -import dev.isxander.controlify.config.ConfigManager; -import dev.isxander.controlify.config.dto.profile.defaults.DefaultConfigManager; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.contextual.ContextualDomains; -import dev.isxander.controlify.contextual.RuleSetManager; -import dev.isxander.controlify.controller.*; -import dev.isxander.controlify.controller.dualsense.TriggerEffectManager; -import dev.isxander.controlify.controller.id.ControllerTypeManager; -import dev.isxander.controlify.controller.input.ControllerState; -import dev.isxander.controlify.controller.input.ControllerStateView; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.controller.rumble.RumbleComponent; -import dev.isxander.controlify.controllermanager.ControllerManager; -import dev.isxander.controlify.controllermanager.SDLControllerManager; -import dev.isxander.controlify.driver.sdl.SDLNativesLoader; -import dev.isxander.controlify.driver.steamdeck.SteamDeckMode; -import dev.isxander.controlify.driver.steamdeck.SteamDeckUtil; -import dev.isxander.controlify.font.InputFontMapper; -import dev.isxander.controlify.gui.screen.*; -import dev.isxander.controlify.debug.DebugProperties; -import dev.isxander.controlify.ingame.ControllerPlayerMovement; -import dev.isxander.controlify.platform.client.PlatformClientUtil; -import dev.isxander.controlify.platform.main.PlatformMainUtil; -import dev.isxander.controlify.platform.network.SidedNetworkApi; -import dev.isxander.controlify.rumble.RumbleManager; -import dev.isxander.controlify.screenop.keyboard.KeyboardLayoutManager; -import dev.isxander.controlify.server.*; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.api.event.ControlifyEvents; -import dev.isxander.controlify.gui.guide.InGameButtonGuide; -import dev.isxander.controlify.ingame.InGameInputHandler; -import dev.isxander.controlify.mixins.feature.virtualmouse.MouseHandlerAccessor; -import dev.isxander.controlify.server.packets.*; -import dev.isxander.controlify.sound.ControlifyClientSounds; -import dev.isxander.controlify.utils.*; -import dev.isxander.controlify.virtualmouse.VirtualMouseHandler; -import dev.isxander.controlify.wireless.LowBatteryNotifier; -import dev.isxander.sdl.Sdl; -import dev.isxander.yacl3.gui.YACLScreen; -import net.minecraft.client.KeyMapping; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.screens.PauseScreen; -import net.minecraft.client.multiplayer.ServerData; -import net.minecraft.network.chat.Component; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.spongepowered.asm.mixin.MixinEnvironment; - -import java.io.IOException; -import java.util.*; -import java.util.stream.Collectors; - -import static dev.isxander.controlify.utils.ControllerUtils.wrapControllerError; - -public class Controlify implements ControlifyApi { - private static Controlify instance = null; - - private Minecraft minecraft = null; - - private ControllerManager controllerManager; - - private ControllerEntity currentController = null; - private InputMode currentInputMode = InputMode.KEYBOARD_MOUSE; - - private @Nullable InGameInputHandler inGameInputHandler; - public @Nullable InGameButtonGuide inGameButtonGuide; - private VirtualMouseHandler virtualMouseHandler; - - // Asset reloaders / managers - private InputFontMapper inputFontMapper; - private DefaultBindManager defaultBindManager; - private DefaultConfigManager defaultConfigManager; - private ControllerTypeManager controllerTypeManager; - private KeyboardLayoutManager keyboardLayoutManager; - - private TriggerEffectManager triggerEffectManager; - - private Set thisTickContexts; - - private ConfigManager config; - - private final Queue setupWizards = new ArrayDeque<>(); - private ControllerSetupWizard currentSetupWizard = null; - private boolean hasDiscoveredControllers = false; - - private int consecutiveInputSwitches = 0; - private double lastInputSwitchTime = 0; - - private int showMouseTicks = 0; - private boolean mouseUsedThisTick = false; - - /** - * Called at usual fabric client entrypoint and in NeoForge mod constructor - * Always runs, even with no controllers detected. - * In this state, Controlify is only partially loaded, no controllers - * have been initialised, nor has the config. This is done at {@link Controlify#initializeControlify()}. - * This is where regular fabric callbacks and forge events should be registered. - * On NeoForge, this is run so early that Minecraft.getInstance() has not yet been set. So extra care should be taken as to not use it. - */ - public void preInitialiseControlify() { - DebugProperties.printProperties(); - - CUtil.LOGGER.log("Pre-initializing Controlify..."); - - if (DebugProperties.MIXIN_AUDIT) { - MixinEnvironment.getCurrentEnvironment().audit(); - } - - this.config = new ConfigManager( - PlatformMainUtil.getConfigDir() - ); - PlatformClientUtil.registerClientStopping(_ -> { - this.config.close(); - }); - - this.inputFontMapper = new InputFontMapper(); - this.defaultBindManager = new DefaultBindManager(); - this.defaultConfigManager = new DefaultConfigManager(); - this.controllerTypeManager = new ControllerTypeManager(); - this.keyboardLayoutManager = new KeyboardLayoutManager(); - - PlatformClientUtil.registerAssetReloadListener(inputFontMapper); - PlatformClientUtil.registerAssetReloadListener(defaultBindManager); - PlatformClientUtil.registerAssetReloadListener(defaultConfigManager); - PlatformClientUtil.registerAssetReloadListener(controllerTypeManager); - PlatformClientUtil.registerAssetReloadListener(keyboardLayoutManager); - PlatformClientUtil.registerAssetReloadListener(RadialIconManager.INSTANCE); - PlatformClientUtil.registerAssetReloadListener(RuleSetManager.GUIDE_RULES); - PlatformClientUtil.registerAssetReloadListener(RuleSetManager.TRIGGER_EFFECT_RULES); - ContextualDomains.touch(); - - registerBuiltinPack("legacy_console"); - - ControlifyClientSounds.init(); - - ControlifyHandshake.setupOnClient(); - - SidedNetworkApi.S2C().listenForPacket(VibrationPacket.CHANNEL, packet -> { - if (config().getSettings().globalSettings().allowServerRumble) { - getCurrentController().flatMap(ControllerEntity::rumble).ifPresent(rumble -> - rumble.rumbleManager().play(packet.source(), packet.createEffect())); - } - }); - SidedNetworkApi.S2C().listenForPacket(OriginVibrationPacket.CHANNEL, packet -> { - if (config().getSettings().globalSettings().allowServerRumble) { - getCurrentController().flatMap(ControllerEntity::rumble).ifPresent(rumble -> - rumble.rumbleManager().play(packet.source(), packet.createEffect())); - } - }); - SidedNetworkApi.S2C().listenForPacket(EntityVibrationPacket.CHANNEL, packet -> { - if (config().getSettings().globalSettings().allowServerRumble) { - getCurrentController().flatMap(ControllerEntity::rumble).ifPresent(rumble -> - rumble.rumbleManager().play(packet.source(), packet.createEffect())); - } - }); - SidedNetworkApi.S2C().listenForPacket(ServerPolicyPacket.CHANNEL, packet -> { - CUtil.LOGGER.log("Connected server specified '{}' policy is {}.", packet.id(), packet.allowed() ? "ALLOWED" : "DISALLOWED"); - ServerPolicies policy = ServerPolicies.getById(packet.id()); - if (policy == null) { - CUtil.LOGGER.warn("Connected server specified unknown '{}' policy; ignoring it.", packet.id()); - return; - } - policy.set(ServerPolicy.fromBoolean(packet.allowed())); - }); - - PlatformClientUtil.registerClientDisconnected(_ -> { - DebugLog.log("Disconnected from server, resetting server policies"); - ServerPolicies.unsetAll(); - }); - PlatformClientUtil.registerClientTagsUpdated(_ -> { - ContextualDomains.INSTANCE.invalidateResolvedFactGraphs(); - RuleSetManager.GUIDE_RULES.invalidateResolvedRuleEngines(); - RuleSetManager.TRIGGER_EFFECT_RULES.invalidateResolvedRuleEngines(); - }); - - PlatformClientUtil.addHudLayer(CUtil.rl("button_guide"), (graphics, deltaTracker) -> - inGameButtonGuide().ifPresent(guide -> guide.extractRenderState(graphics, deltaTracker.getGameTimeDeltaPartialTick(false)))); - - PlatformMainUtil.applyToControlifyEntrypoint(entrypoint -> { - try { - entrypoint.onControlifyPreInit(new PreInitContext() { - @Override - public ControlifyBindApi bindings() { - return ControlifyBindApiImpl.INSTANCE; - } - - @Override - public ContextualDomainRegistry contextualDomains() { - return ContextualDomains.INSTANCE; - } - }); - } catch (Throwable e) { - CUtil.LOGGER.error("Failed to run `onControlifyPreInit` on Controlify entrypoint: {}", entrypoint.getClass().getName(), e); - } - }); - } - - private void registerBuiltinPack(String id) { - PlatformClientUtil.registerBuiltinResourcePack( - CUtil.rl(id), - Component.translatable("controlify.extra_pack." + id + ".name") - ); - } - - /** - * Called once Minecraft has completely loaded. - * (When the loading overlay starts to fade). - * - * This is where controllers are usually initialised, as long - * as one or more controllers are connected. - */ - public void initializeControlify() { - CUtil.LOGGER.log("Initializing Controlify..."); - this.minecraft = Minecraft.getInstance(); - - config().loadOrDefault(); - - this.inGameInputHandler = null; // set when the current controller changes - this.virtualMouseHandler = new VirtualMouseHandler(); - this.triggerEffectManager = new TriggerEffectManager(); - - ControlifyEvents.CONTROLLER_CONNECTED.register(event -> this.onControllerAdded( - event.controller(), event.hotplugged())); - ControlifyEvents.CONTROLLER_DISCONNECTED.register(event -> this.onControllerRemoved(event.controller())); - - ControlifyBindings.registerModdedBindings(); - - PlatformClientUtil.registerPostScreenRender((screen, graphics, mouseX, mouseY, tickDelta) -> - ControlifyApi.get().getCurrentController().ifPresent(controller -> { - virtualMouseHandler().renderVirtualMouse(graphics); - ScreenProcessorProvider.provide(screen).render(controller, graphics, tickDelta); - })); - - try { - Sdl sdl = SDLNativesLoader.load(); - controllerManager = new SDLControllerManager(sdl, CUtil.LOGGER); - } catch (Throwable throwable) { - CUtil.LOGGER.error("Failed to initialize controller manager", throwable); - return; - } - - PlatformClientUtil.registerClientTickStarted(this::tick); - - // initialise and compatability modules that controlify implements itself - // this does NOT invoke any entrypoints. this is done in the pre-initialisation phase - ControlifyCompat.init(); - - // make sure people don't someone add binds after controllers could have been created - ControlifyBindApiImpl.INSTANCE.lock(); - - discoverControllers(); - - if (DebugProperties.INIT_DUMP) { - CUtil.LOGGER.log("\n{}", DebugDump.dumpDebug()); - } - - if (this.config().getSettings().globalSettings().useEnhancedSteamDeckDriver) { - doSteamDeckChecks(); - } - - PlatformMainUtil.applyToControlifyEntrypoint(entrypoint -> { - try { - entrypoint.onControlifyInit(new InitContext() { - @Override - public ControlifyApi controlify() { - return Controlify.this; - } - }); - } catch (Throwable e) { - CUtil.LOGGER.error("Failed to run `onControlifyInit` on Controlify entrypoint: {}", entrypoint.getClass().getName(), e); - } - }); - } - - private void doSteamDeckChecks() { - CUtil.LOGGER.log("Steam Deck state: {}", SteamDeckUtil.DECK_MODE); - - if (!SteamDeckUtil.IS_STEAM_DECK && DebugProperties.STEAM_DECK_CUSTOM_CEF_URL == null) { - return; - } - - boolean connectedToCef = SteamDeckUtil.getDeckInstance().isPresent(); - - if (!connectedToCef) { - CUtil.LOGGER.error("Controlify could not connect to CEF debugger instance. Decky is probably not installed."); - InitialScreenRegistryDuck.registerInitialScreen(SteamDeckAlerts::createDeckyRequiredWarning); - } - - if (SteamDeckUtil.DECK_MODE == SteamDeckMode.DESKTOP_MODE) { - CUtil.LOGGER.warn("Controlify is running in SteamOS desktop mode."); - InitialScreenRegistryDuck.registerInitialScreen(SteamDeckAlerts::createDesktopModeWarning); - } - - if (connectedToCef && SteamDeckUtil.DECK_MODE == SteamDeckMode.GAMING_MODE) { - CUtil.LOGGER.log("Steam Deck is in gaming mode and Controlify has successfully connected to CEF."); - } - } - - /** - * Loops through every controller slot and initialises it if it is connected. - * This is guaranteed to be called at most once. If no controllers are connected - * in the whole game lifecycle, this is never ran. - */ - public void discoverControllers() { - if (hasDiscoveredControllers) { - CUtil.LOGGER.warn("Attempted to discover controllers twice!"); - return; - } - hasDiscoveredControllers = true; - - DebugLog.log("Discovering and initializing controllers..."); - - controllerManager.discoverControllers(); - - if (controllerManager.getConnectedControllers().isEmpty()) { - CUtil.LOGGER.log("No controllers found."); - } - - this.applyControllerSelection(false); - - config().saveIfDirty(); - - PlatformMainUtil.applyToControlifyEntrypoint(entrypoint -> { - try { - entrypoint.onControllersDiscovered(this); - } catch (Throwable e) { - CUtil.LOGGER.error("Failed to run `onControllersDiscovered` on Controlify entrypoint: {}", entrypoint.getClass().getName(), e); - } - }); - } - - /** - * Called when a controller is connected. Either from controller - * discovery or hotplugging. - * - * @param controller the new controller - * @param hotplugged if this was a result of hotplugging - */ - private void onControllerAdded(ControllerEntity controller, boolean hotplugged) { - ControllerSetupWizard wizard = new ControllerSetupWizard(); - DeviceSettings rememberedDevice = config().getSettings().getOrCreateDeviceSettings(controller.uid()); - rememberedDevice.name = controller.name(); - rememberedDevice.lastSeen = System.currentTimeMillis(); - rememberedDevice.controllerType = controller.info().type().namespace(); - config().markDirty(); - - String selectedUid = config().getActiveProfile().controllerUid; - if (selectedUid == null || selectedUid.equals(controller.uid())) { - this.setCurrentController(controller, true); - } - - // wizard.addStage(() -> SubmitUnknownControllerScreen.canSubmit(controller), nextScreen -> new SubmitUnknownControllerScreen(controller, nextScreen)); - - // Calibration screen removed - gyro calibration is now automatic via rolling calibration - - wizard.addStage( - () -> { - Optional inputOpt = controller.input(); - if (inputOpt.isPresent()) { - InputComponent input = inputOpt.get(); - DeviceSettings deviceSettings = config().getSettings().getOrCreateDeviceSettings(controller.uid()); - return !input.isDefinitelyGamepad() && deviceSettings.mapping == null; - } - return false; - }, - nextScreen -> new AskToMapControllerScreen(controller, nextScreen) - ); - wizard.addStage( - () -> controller.dualSense().isPresent() && controller.bluetooth().map(bt -> !bt.settings().dontShowWarning).orElse(false), - nextScreen -> new BluetoothWarningScreen(controller.bluetooth().orElseThrow(), nextScreen) - ); - - if (hotplugged) { - MinecraftUtil.sendToast( - Component.translatable("controlify.toast.controller_connected.title"), - Component.translatable("controlify.toast.controller_connected.description", controller.name()), - false - ); - } - - // saved after discovery - if (hotplugged) { - config().saveIfDirty(); - } - - setupWizards.add(wizard); - } - - /** - * Called when a controller is disconnected. - * @param controller controller that has been disconnected - */ - private void onControllerRemoved(ControllerEntity controller) { - if (this.getCurrentController().isPresent() && getCurrentController().get().equals(controller)) { - if (config().getActiveProfile().controllerUid == null) { - this.selectFirstConnectedController(); - } else { - this.setCurrentController(null, true); - } - } - - MinecraftUtil.sendToast( - Component.translatable("controlify.toast.controller_disconnected.title"), - Component.translatable("controlify.toast.controller_disconnected.description", controller.name()), - false - ); - } - - private void selectFirstConnectedController() { - Optional firstController = controllerManager.getConnectedControllers() - .stream() - .findFirst(); - this.setCurrentController(firstController.orElse(null), true); - } - - /** - * The main loop of Controlify. - * Only the current controller ticks. - */ - public void tick(Minecraft client) { - if (MinecraftUtil.getOverlay() == null) { - if (currentSetupWizard != null && currentSetupWizard.isDone()) { - currentSetupWizard = null; - } - - if (!setupWizards.isEmpty() && !(MinecraftUtil.getScreen() instanceof DontInterruptScreen)) { - currentSetupWizard = setupWizards.poll(); - MinecraftUtil.setScreen(currentSetupWizard.start(MinecraftUtil.getScreen())); - } - } - - boolean outOfFocus = !config().getSettings().globalSettings().outOfFocusInput && !client.isWindowActive(); - - this.thisTickContexts = BindContext.CONTEXTS.values().stream() - .filter(ctx -> ctx.isApplicable().apply(minecraft)) - .collect(Collectors.toUnmodifiableSet()); - - // handles updating state of all controllers - controllerManager.tick(outOfFocus); - - // handle showing/hiding mouse whilst in mixed input mode - if (minecraft.mouseHandler.isMouseGrabbed()) - showMouseTicks = 0; - if (currentInputMode() == InputMode.MIXED && showMouseTicks > 0) { - showMouseTicks--; - if (showMouseTicks == 0) { - restoreVirtualCursor(); - } - } - - LowBatteryNotifier.tick(); - - getCurrentController().ifPresent(currentController -> { - wrapControllerError( - () -> tickActiveController(currentController, outOfFocus), - "Ticking current controller", - currentController - ); - }); - for (ControllerEntity controller : controllerManager.getConnectedControllers()) { - if (controller.equals(getCurrentController().orElse(null))) continue; - - wrapControllerError( - () -> tickInactiveController(controller), - "Ticking inactive controller", - controller - ); - } - - mouseUsedThisTick = false; - } - - /** - * Ticks a specific controller. - * - * @param controller controller to tick - * @param outOfFocus if the window is out of focus - */ - private void tickActiveController(ControllerEntity controller, boolean outOfFocus) { - InputComponent input = controller.input().orElseThrow(); - ControllerStateView state = input.stateNow(); - Optional rumbleManager = controller.rumble().map(RumbleComponent::rumbleManager); - - boolean isPaused = minecraft.isPaused() || MinecraftUtil.getScreen() instanceof PauseScreen; - boolean isConfigScreen = MinecraftUtil.getScreen() instanceof YACLScreen; - - rumbleManager.ifPresent(rumble -> rumble.setSilent(outOfFocus || (isPaused && !isConfigScreen) || currentInputMode() == InputMode.KEYBOARD_MOUSE)); - if (outOfFocus) { - state = ControllerState.EMPTY; - } else { - rumbleManager.ifPresent(RumbleManager::tick); - } - - boolean controllerInputSuppressed = outOfFocus || currentInputMode() == InputMode.KEYBOARD_MOUSE; - this.triggerEffectManager().tick(this.minecraft, controller); - this.triggerEffectManager().applyTriggerEffects(controller, controllerInputSuppressed); - - if (state.isGivingInput()) { - minecraft.getFramerateLimitTracker().onInputReceived(); - - if (!this.currentInputMode().isController()) { - this.setInputMode(config().getSettings().globalSettings().mixedInput ? InputMode.MIXED : InputMode.CONTROLLER); - - return; // don't process input if this is changing mode. - } - } - - if (showMouseTicks > 0 && !mouseUsedThisTick && state.isGivingInput()) { - this.endTemporaryCursor(); - } - - if (consecutiveInputSwitches > 100) { - CUtil.LOGGER.warn("Controlify detected current controller to be constantly giving input and has been disabled."); - MinecraftUtil.sendToast( - Component.translatable("controlify.toast.faulty_input.title"), - Component.translatable("controlify.toast.faulty_input.description"), - true - ); - this.setCurrentController(null, true); - consecutiveInputSwitches = 0; - return; - } - - if (minecraft.level != null) { - this.inGameInputHandler().ifPresent(InGameInputHandler::inputTick); - } - - if (this.currentInputMode().isController()) { - if (MinecraftUtil.getScreen() != null) { - ScreenProcessorProvider.provide(MinecraftUtil.getScreen()).onControllerUpdate(controller); - } - - ControlifyEvents.ACTIVE_CONTROLLER_TICKED.invoke(new ControlifyEvents.ControllerStateUpdate(controller)); - } - } - - private void tickInactiveController(ControllerEntity controller) { - if (config().getActiveProfile().controllerUid != null) { - return; - } - - InputComponent input = controller.input().orElseThrow(); - ControllerStateView state = input.stateNow(); - - boolean thisControllerGivingInput = state.isGivingInput(); - boolean activeControllerGivingInput = getCurrentController().map(c -> c.input().orElseThrow().stateNow().isGivingInput()).orElse(false); - - if (thisControllerGivingInput && !activeControllerGivingInput) { - this.setCurrentController(controller, true); - } - } - - public void applyControllerSelection(boolean changeInputMode) { - String selectedUid = config().getActiveProfile().controllerUid; - Optional selected = controllerManager == null - ? Optional.empty() - : controllerManager.getConnectedControllers().stream() - .filter(controller -> selectedUid == null || selectedUid.equals(controller.uid())) - .findFirst(); - this.setCurrentController(selected.orElse(null), changeInputMode); - } - - public boolean switchProfile(int profileIndex, boolean remember) throws IOException { - if (!config().switchProfile(profileIndex, remember)) { - return false; - } - - ProfileSettings profile = config().getActiveProfile(); - if (controllerManager != null) { - controllerManager.getConnectedControllers().forEach(controller -> controller.setSettings(profile)); - } - this.applyControllerSelection(true); - this.setupForController(this.currentInputMode.isController() ? this.currentController : null); - return true; - } - - public ConfigManager config() { - return config; - } - - @Override - public @NotNull Optional getCurrentController() { - return Optional.ofNullable(currentController); - } - - public void setCurrentController(@Nullable ControllerEntity controller, boolean changeInputMode) { - if (this.currentController == controller) return; - this.currentController = controller; - - boolean changedInputMode = false; - if (controller == null) { - changedInputMode = this.setInputMode(InputMode.KEYBOARD_MOUSE); - DebugLog.log("Cleared current controller."); - } else { - changedInputMode = this.setInputMode(config().getSettings().globalSettings().mixedInput ? InputMode.MIXED : InputMode.CONTROLLER); - DebugLog.log("Updated current controller to {}({})", controller.name(), controller.uid()); - } - if (!changedInputMode) { - this.setupForController(controller); - } - } - - @Override - public boolean setInputMode(@NotNull InputMode newInputMode) { - if (this.currentInputMode == newInputMode) return false; - if (newInputMode.isController() && this.getCurrentController().isEmpty()) { - DebugLog.log("Attempted to switch to controller input mode with no current controller set."); - return false; - } - - this.currentInputMode = newInputMode; - - // Track consecutive input mode switches to prevent softlock - if (Blaze3D.getTime() - lastInputSwitchTime < 20) { - consecutiveInputSwitches++; - } else { - consecutiveInputSwitches = 0; - } - lastInputSwitchTime = Blaze3D.getTime(); - - if (!minecraft.mouseHandler.isMouseGrabbed()) { - if (newInputMode == InputMode.KEYBOARD_MOUSE && virtualMouseHandler().isVirtualMouseEnabled()) { - moveCursorToVirtualMouse(); - } - hideMouse(newInputMode.isController(), true); - } - - this.setupForController(this.currentInputMode.isController() ? this.currentController : null); - - KeyMapping.resetToggleKeys(); - - // If we have already joined a server with KB&M, then switch to controller, - // we should do the new server notification as it won't have been triggered on join. - if (this.currentInputMode.isController()) { - if (minecraft.getCurrentServer() != null) { - notifyNewServer(minecraft.getCurrentServer()); - } - } - - // notify current screen of input mode change - if (MinecraftUtil.getScreen() != null) { - ScreenProcessorProvider.provide(MinecraftUtil.getScreen()).onInputModeChanged(newInputMode); - } - - // notify event listeners of input mode change - ControlifyEvents.INPUT_MODE_CHANGED.invoke(new ControlifyEvents.InputModeChanged(newInputMode)); - - return true; - } - - private void setupForController(@Nullable ControllerEntity controller) { - ControllerPlayerMovement.updatePlayerInput(minecraft.player); - - if (controller == null) { - this.inGameInputHandler = null; - this.inGameButtonGuide = null; - return; - } - - this.inGameInputHandler = new InGameInputHandler(controller); - this.inGameButtonGuide = new InGameButtonGuide(controller, this.minecraft); - - controller.input().ifPresent(input -> { - input.rawStateNow().clearState(); - input.rawStateThen().clearState(); - }); - } - - public Optional getControllerManager() { - return Optional.ofNullable(controllerManager); - } - - public Optional inGameInputHandler() { - return Optional.ofNullable(inGameInputHandler); - } - - public Optional inGameButtonGuide() { - return Optional.ofNullable(inGameButtonGuide); - } - - public VirtualMouseHandler virtualMouseHandler() { - return virtualMouseHandler; - } - - public @NotNull InputMode currentInputMode() { - return currentInputMode; - } - - public void hideMouse(boolean hide, boolean moveMouse) { - CursorUtils.setVisibility(minecraft.getWindow(), !hide); - - if (MinecraftUtil.getScreen() != null) { - var mouseHandlerAccessor = (MouseHandlerAccessor) minecraft.mouseHandler; - if (hide && !virtualMouseHandler().isVirtualMouseEnabled() && !virtualMouseHandler().requiresVirtualMouse() && moveMouse) { - long handle = minecraft.getWindow().handle(); - // stop mouse hovering over last element before hiding cursor but don't actually move it - // so when the user switches back to mouse it will be in the same place - mouseHandlerAccessor.controlify$invokeOnMove(handle, -50, -50 /*? if >=26.3 {*/, 0, 0/*?}*/); - } - } - } - - public void showCursorTemporarily() { - if (currentInputMode() == InputMode.MIXED && !minecraft.mouseHandler.isMouseGrabbed()) { - mouseUsedThisTick = true; - if (virtualMouseHandler().isVirtualMouseEnabled()) { - moveCursorToVirtualMouse(); - virtualMouseHandler().disableVirtualMouse(); - } - hideMouse(false, false); - showMouseTicks = 20 * 2; - } - } - - private void moveCursorToVirtualMouse() { - CursorUtils.setPosition( - minecraft.getWindow(), - virtualMouseHandler().getCurrentX(0), - virtualMouseHandler().getCurrentY(0) - ); - } - - public void endTemporaryCursor() { - if (currentInputMode() != InputMode.MIXED || showMouseTicks <= 0) return; - - showMouseTicks = 0; - restoreVirtualCursor(); - } - - private void restoreVirtualCursor() { - hideMouse(true, false); - if (virtualMouseHandler().requiresVirtualMouse()) { - virtualMouseHandler().enableVirtualMouse(); - } - } - - public InputFontMapper inputFontMapper() { - return inputFontMapper; - } - - public DefaultBindManager defaultBindManager() { - return defaultBindManager; - } - - public DefaultConfigManager defaultConfigManager() { - return defaultConfigManager; - } - - public ControllerTypeManager controllerTypeManager() { - return controllerTypeManager; - } - - public KeyboardLayoutManager keyboardLayoutManager() { - return keyboardLayoutManager; - } - - public TriggerEffectManager triggerEffectManager() { - return triggerEffectManager; - } - - public Set thisTickBindContexts() { - return this.thisTickContexts; - } - - public void notifyNewServer(ServerData data) { - if (!currentInputMode().isController()) - return; - - if (config().getSettings().globalSettings().seenServers.add(data.ip)) { - MinecraftUtil.sendToast( - Component.translatable("controlify.toast.new_server.title"), - Component.translatable("controlify.toast.new_server.description", data.name), - true - ); - config().saveSafely(); - } - } - - public static Controlify instance() { - if (instance == null) instance = new Controlify(); - return instance; - } -} diff --git a/src/main/java/dev/isxander/controlify/api/ControlifyApi.java b/src/main/java/dev/isxander/controlify/api/ControlifyApi.java index 0a169145f..84f091ad0 100644 --- a/src/main/java/dev/isxander/controlify/api/ControlifyApi.java +++ b/src/main/java/dev/isxander/controlify/api/ControlifyApi.java @@ -6,45 +6,43 @@ */ package dev.isxander.controlify.api; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.rumble.RumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import org.jetbrains.annotations.NotNull; +import dev.isxander.controlify.api.contextual.BuiltinContextualDomains; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.impl.Controlify; +import net.minecraft.resources.Identifier; +import java.util.Collection; import java.util.Optional; -/** - * Interface with Controlify in a manner where you don't need to worry about updates - * breaking! This is the recommended way to interact with Controlify. - *

- * Alternatively, to use Controlify directly, you can use {@link Controlify#instance()}. Though - * beware, things may break at any time! - *

- * Anything that is asked for from this API is safe to use, even if it is not in the API package. - */ +/// Live client queries and operations. Access through {@link #get()} or event payloads; +/// consumers do not need to retain the API or reconstruct state from notifications. public interface ControlifyApi { - /** - * The controller that is currently enabled and in use. - * If there is no controller disconnected or disabled, this will return {@link Optional#empty()}. - * This is the controller that is used for {@link dev.isxander.controlify.api.event.ControlifyEvents#ACTIVE_CONTROLLER_TICKED} - */ - @NotNull Optional getCurrentController(); - - /** - * The last input received: a controller or keyboard/mouse. - */ - @NotNull InputMode currentInputMode(); - boolean setInputMode(@NotNull InputMode mode); - - default void playRumbleEffect(@NotNull RumbleSource rumbleSource, @NotNull RumbleEffect rumbleEffect) { - getCurrentController() - .flatMap(ControllerEntity::rumble) - .ifPresent(r -> r.rumbleManager().play(rumbleSource, rumbleEffect)); - } - + /// Returns the shared client API without initializing its subsystems. Action-spec and + /// contextual-domain registries must still be obtained through their registration events; INITIALIZED + /// indicates when runtime managers are ready, rather than granting access to this API. static ControlifyApi get() { return Controlify.instance(); } + + Optional getCurrentController(); + + /// An unmodifiable snapshot of the currently connected controllers. + Collection getConnectedControllers(); + + Optional getControllerByUid(String uid); + + /// Access to built-in contextual domains throughout the client lifetime, without + /// exposing the new-domain registration operations supplied during REGISTER_DOMAINS. + BuiltinContextualDomains contextualDomains(); + + InputMode currentInputMode(); + + /// Returns whether the mode changed. Controller modes require a current controller. + boolean setInputMode(InputMode mode); + + default Optional playRumbleEffect(Identifier effectId, RumbleEffect effect) { + return getCurrentController().flatMap(Controller::rumble).map(rumble -> rumble.playEffect(effectId, effect)); + } } diff --git a/src/main/java/dev/isxander/controlify/api/ControlifyEntrypoint.java b/src/main/java/dev/isxander/controlify/api/ControlifyEntrypoint.java new file mode 100644 index 000000000..8e784591b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/ControlifyEntrypoint.java @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api; + +/// Registers addon event listeners before Controlify starts client setup. +/// Use Fabric's `controlify` entrypoint or Java's ServiceLoader on either loader. +/// Called once per provider class, client-side, after platform services are installed. +/// Minecraft and Controlify's managers are not yet available; obtain managers from events. +@FunctionalInterface +public interface ControlifyEntrypoint { + void onControlifyInitialize(); +} diff --git a/src/main/java/dev/isxander/controlify/api/ControlifyEvents.java b/src/main/java/dev/isxander/controlify/api/ControlifyEvents.java new file mode 100644 index 000000000..9bc4900eb --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/ControlifyEvents.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api; + +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.Controlify; + +public final class ControlifyEvents { + /// Fires once when runtime managers and queries are usable, before backend setup + /// and discovery. Queries initially return no controllers. Still fires if backend setup fails. + public static final EventSource INITIALIZED = Controlify.Events.INITIALIZED; + + /// Fires after Controlify has applied the new input mode and updated its handlers. + public static final EventSource INPUT_MODE_CHANGED = Controlify.Events.INPUT_MODE_CHANGED; + + public record InputModeChanged(ControlifyApi controlify, InputMode mode) {} + + private ControlifyEvents() {} +} diff --git a/src/main/java/dev/isxander/controlify/InputMode.java b/src/main/java/dev/isxander/controlify/api/InputMode.java similarity index 89% rename from src/main/java/dev/isxander/controlify/InputMode.java rename to src/main/java/dev/isxander/controlify/api/InputMode.java index 3bedace21..fb6162b8e 100644 --- a/src/main/java/dev/isxander/controlify/InputMode.java +++ b/src/main/java/dev/isxander/controlify/api/InputMode.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify; +package dev.isxander.controlify.api; public enum InputMode { KEYBOARD_MOUSE, diff --git a/src/main/java/dev/isxander/controlify/api/action/Action.java b/src/main/java/dev/isxander/controlify/api/action/Action.java new file mode 100644 index 000000000..540d957c5 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/Action.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import dev.isxander.controlify.api.input.LogicalInput; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/// An action is bound to any number of logical inputs and has an activator tree +/// that determines how its output channels respond to them. +public interface Action { + ActionSpec spec(); + + Channel.Pulse pulse(); + Channel.Latch latch(); + Channel.Continuous continuous(); + + /// The current bindings, in display order. Empty when unbound. + List bindings(); + + /// Replaces every binding. An empty list unbinds the action. + void setBindings(List bindings); + + /// The bindings this action has by default for the current controller type. + List defaultBindings(); + + /// The input shown in prompts and button glyphs: the first binding's, or null when unbound. + default @Nullable LogicalInput primaryInput() { + List bindings = this.bindings(); + return bindings.isEmpty() ? null : bindings.getFirst().input(); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionAccessor.java b/src/main/java/dev/isxander/controlify/api/action/ActionAccessor.java new file mode 100644 index 000000000..86200f75a --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionAccessor.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import com.mojang.serialization.Codec; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.controller.input.InputComponent; +import dev.isxander.controlify.impl.action.ActionAccessorImpl; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +import java.util.Optional; + +/// Resolves the current action on a controller or input component. Retain this accessor; +/// profile changes may replace resolved action instances. +public interface ActionAccessor { + Codec CODEC = Identifier.CODEC.xmap( + ActionAccessorImpl::new, ActionAccessor::actionId); + + /// The identifier of the action this accessor tries to retrieve + Identifier actionId(); + + /// Try and access this action on a given [InputComponent], + /// or null if the action is not registered on the given input component. + @Nullable Action onOrNull(InputComponent inputComponent); + + /// Try and access this action on a given [Controller]; + /// convenience function for [onOrNull(InputComponent)]. + /// Or null if the action is not registered on the given controller. + default @Nullable Action onOrNull(Controller controller) { + return this.onOrNull(controller.input()); + } + + /// Try and access this action on a given [InputComponent], + /// or [Optional#empty()] if the action is not registered on the given input component. + default Optional onOptional(InputComponent inputComponent) { + return Optional.ofNullable(this.onOrNull(inputComponent)); + } + + /// Try and access this action on a given [Controller]; + /// convenience function for [onOptional(InputComponent)]. + /// Or [Optional#empty()] if the action is not registered on the given controller. + default Optional onOptional(Controller controller) { + return Optional.ofNullable(this.onOrNull(controller.input())); + } + + /// Try and access this action on a given [InputComponent]. + /// @throws NullPointerException if the action is not registered on the given controller. + default Action on(InputComponent inputComponent) { + Action action = onOrNull(inputComponent); + if (action == null) { + throw new NullPointerException("No action bound to input component " + actionId()); + } + return action; + } + + /// Try and access this action on a given [Controller]; + /// convenience function for [on(InputComponent)]. + /// @throws NullPointerException if the action is not registered on the given controller. + default Action on(Controller controller) { + return on(controller.input()); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionBinding.java b/src/main/java/dev/isxander/controlify/api/action/ActionBinding.java new file mode 100644 index 000000000..843afcc8b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionBinding.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import dev.isxander.controlify.api.input.LogicalInput; + +import java.util.Optional; + +/// One input bound to an action. +/// @param input the input that activates the action +/// @param gesture overrides the gesture of the action's editable activator for this binding, if it has one +/// of the same {@link Gesture.Kind}; ignored otherwise +public record ActionBinding(LogicalInput input, Optional gesture) { + public ActionBinding(LogicalInput input) { + this(input, Optional.empty()); + } + + public static ActionBinding of(LogicalInput input) { + return new ActionBinding(input); + } + + public ActionBinding withGesture(Optional gesture) { + return new ActionBinding(this.input, gesture); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionEvents.java b/src/main/java/dev/isxander/controlify/api/action/ActionEvents.java new file mode 100644 index 000000000..739c115a3 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionEvents.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.action.ActionLayerRegistryImpl; +import dev.isxander.controlify.impl.action.ActionSpecRegistryImpl; +import dev.isxander.controlify.impl.action.proxy.ActionProxyManagerImpl; + +public final class ActionEvents { + /// Fires before [REGISTER_ACTIONS], with built-in layers already registered. + /// Register addon layer IDs and predicates here; the layer registry locks afterwards. + public static final EventSource REGISTER_LAYERS = ActionLayerRegistryImpl.Events.REGISTER_LAYERS; + + /// Fires once with Minecraft and initial resources available, before backend setup + /// or controller construction. Register definitions here; the registry locks afterwards. + /// Returned accessors may be retained and used with connected controllers. + public static final EventSource REGISTER_ACTIONS = ActionSpecRegistryImpl.Events.REGISTER_ACTIONS; + + /// Fires immediately after [REGISTER_ACTIONS], before automatic modded-key actions or controllers are created. + /// Configure proxies and automatic-proxy exclusions here; the manager locks after startup registration. + public static final EventSource REGISTER_PROXIES = ActionProxyManagerImpl.Events.REGISTER_PROXIES; + + private ActionEvents() { + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionLayerMode.java b/src/main/java/dev/isxander/controlify/api/action/ActionLayerMode.java new file mode 100644 index 000000000..264590b0e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionLayerMode.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +/// How an active layer affects the layers ordered below it. +public enum ActionLayerMode { + /// Consumes nothing. Layers below receive every input, including inputs this layer binds. + /// Actions sharing an input with lower layers all receive it. + TRANSPARENT, + /// Consumes only the inputs this layer's actions are bound to. + /// Layers below still receive every other input. + OVERLAY, + /// Consumes every input. No layer below receives anything while this layer is active. + BLOCKING, +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionLayerRegistry.java b/src/main/java/dev/isxander/controlify/api/action/ActionLayerRegistry.java new file mode 100644 index 000000000..cbdfbfff1 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionLayerRegistry.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import net.minecraft.client.Minecraft; +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Contract; + +import java.util.function.Predicate; + +/// Registers layers during {@link ActionEvents#REGISTER_LAYERS}. +/// Layer records remain internal; actions reference their identifiers only. +/// +/// Layers form an ordered stack. Input is delivered from the top of the stack down, +/// and each active layer's {@link ActionLayerMode} decides what reaches the layers below it. +/// Order is declared relative to other layers, so layers may reference ones registered later. +public interface ActionLayerRegistry { + /// Starts registering a layer. Nothing is registered until {@link Builder#register()} is called. + Builder layer(Identifier id); + + interface Builder { + /// Defaults to {@link ActionLayerMode#OVERLAY}. + @Contract("_ -> this") + Builder mode(ActionLayerMode mode); + + /// The predicate evaluated against the current client before controller input is processed. Required. + @Contract("_ -> this") + Builder activeWhen(Predicate isActive); + + /// Places this layer above another. A layer that is never registered is ignored. + @Contract("_ -> this") + Builder above(Identifier other); + + /// Places this layer below another. A layer that is never registered is ignored. + @Contract("_ -> this") + Builder below(Identifier other); + + /// Places this layer above every layer not also placed at the top. + @Contract("-> this") + Builder top(); + + /// Places this layer below every layer not also placed at the bottom. + @Contract("-> this") + Builder bottom(); + + /// Registers the layer and returns its id for use in {@link ActionSpecBuilder#layer(Identifier...)}. + /// Throws if the identifier already exists, no predicate or placement was given, + /// or layer registration has closed. Contradictory placements throw when registration closes. + Identifier register(); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionProxyManager.java b/src/main/java/dev/isxander/controlify/api/action/ActionProxyManager.java new file mode 100644 index 000000000..3c14769f1 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionProxyManager.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import dev.isxander.controlify.api.controller.Controller; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; + +import java.util.function.Predicate; + +/// Configures how existing actions proxy operations in Minecraft. +/// Supplied by {@link ActionEvents#REGISTER_PROXIES}, immediately after action-spec registration. +/// Configure proxies and automatic-proxy exclusions in that event; registrations close after startup setup. +public interface ActionProxyManager { + /// Prevents automatic creation of an action for this key mapping. + /// Does not prevent explicitly proxying it through an existing action. + /// Useless for vanilla key mappings as they are not automatically proxied. + void dontAutoProxyKeyMapping(KeyMapping keyMapping); + + /// Proxies an already registered action to a key mapping, suppressing its automatic proxy action. + /// + /// The listener consumes pulses to generate key clicks and reads the latch for held state. + /// **This means an action that is proxied cannot have its pulses queried normally, because + /// the proxy manager consumes them.** + /// + /// It resolves the action again on each update so profile changes can replace action instances. + /// A key mapping may only be proxied by one action; an action may proxy multiple keys. + default void proxyKeyMapping(ActionAccessor action, KeyMapping keyMapping) { + proxyKeyMapping(action.actionId(), keyMapping); + } + + /// Equivalent to {@link #proxyKeyMapping(ActionAccessor, KeyMapping)} using a registered action ID. + /// Throws if the action is not registered or the key is already proxied by a different action. + default void proxyKeyMapping(Identifier actionId, KeyMapping keyMapping) { + proxyKeyMapping(actionId, keyMapping, controller -> false); + } + + /// Proxies an action with a controller-dependent toggle setting. + /// When the predicate is true, each pulse toggles the key's held state; otherwise the latch supplies it. + /// Toggle state clears when the action resets or the controller stops owning the proxy. + /// The action must produce pulses for toggle mode and a latch for hold mode. + default void proxyKeyMapping(ActionAccessor action, KeyMapping keyMapping, Predicate toggleCondition) { + proxyKeyMapping(action.actionId(), keyMapping, toggleCondition); + } + + /// Equivalent to {@link #proxyKeyMapping(ActionAccessor, KeyMapping, Predicate)} using an action ID. + void proxyKeyMapping(Identifier actionId, KeyMapping keyMapping, Predicate toggleCondition); +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionSpec.java b/src/main/java/dev/isxander/controlify/api/action/ActionSpec.java new file mode 100644 index 000000000..a91c2da28 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionSpec.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.impl.action.ActionSpecBuilderImpl; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; + +import java.util.Comparator; +import java.util.Set; +import java.util.function.Predicate; + +/// Immutable information about an {@link Action}. +/// +/// A lot of information about an action is data-driven and cannot be defined in code; +/// - to define this action's activator, do so in +/// `assets/controlify/controller/activators.json` +/// - to allow an action to be driven by the radial menu, define its icon +/// in `assets/controlify/controller/radial_icons.json` +/// - to define default bindings (per controller type), use +/// `assets/controlify/controller/default_bind/default.json` +/// +/// @param id the unique identifier for the action +/// @param name the display name for the action +/// @param description the display description for the action +/// @param category the category for the action +/// @param contexts the set of context IDs that this action should work in +/// @param shouldExpose predicate deciding if the action should be visible in the settings and available +/// to bind for the current controller. even if this predicate resolves to false the action +/// will be created for the given controller, the only difference is that it is not visible +/// to the user +public record ActionSpec( + Identifier id, + Component name, + Component description, + Component category, + Set contexts, + Predicate shouldExpose +) implements Comparable { + public ActionSpec { + contexts = Set.copyOf(contexts); + } + + @Override + public int compareTo(ActionSpec o) { + return Comparator.comparing(ActionSpec::id) + .compare(this, o); + } + + public static ActionSpecBuilder builder() { + return new ActionSpecBuilderImpl(); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionSpecBuilder.java b/src/main/java/dev/isxander/controlify/api/action/ActionSpecBuilder.java new file mode 100644 index 000000000..e24f40478 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionSpecBuilder.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import dev.isxander.controlify.api.controller.Controller; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Contract; + +import java.util.function.Predicate; + +public interface ActionSpecBuilder { + @Contract("_ -> this") + ActionSpecBuilder id(Identifier id); + + @Contract("_ -> this") + ActionSpecBuilder name(Component name); + + @Contract("_ -> this") + ActionSpecBuilder description(Component description); + + @Contract("_ -> this") + ActionSpecBuilder category(Component category); + + /// Accepts IDs from {@link BuiltinActionLayers} or addon IDs registered during {@link ActionEvents#REGISTER_LAYERS}. + /// An action in several layers receives input through whichever of them is reachable. + @Contract("_ -> this") + ActionSpecBuilder layer(Identifier... contexts); + + @Contract("_ -> this") + ActionSpecBuilder shouldExpose(Predicate predicate); + + ActionSpec build(); +} diff --git a/src/main/java/dev/isxander/controlify/api/action/ActionSpecRegistry.java b/src/main/java/dev/isxander/controlify/api/action/ActionSpecRegistry.java new file mode 100644 index 000000000..aec0179be --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/ActionSpecRegistry.java @@ -0,0 +1,13 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +/// Receives action definitions during {@link ActionEvents#REGISTER_ACTIONS}. +public interface ActionSpecRegistry { + /// Throws if registration has closed or the identifier is already registered. + ActionAccessor register(ActionSpec actionSpec); +} diff --git a/src/main/java/dev/isxander/controlify/api/action/BuiltinActionLayers.java b/src/main/java/dev/isxander/controlify/api/action/BuiltinActionLayers.java new file mode 100644 index 000000000..b3d0c4051 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/BuiltinActionLayers.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; + +/// IDs of Controlify's built-in action layers. Use these directly in {@link ActionSpecBuilder#layer(Identifier...)}. +/// Referencing these constants does not initialize or register layer implementations. +/// +/// The built-in stack, from top to bottom: +/// {@link #UNKNOWN}, {@link #RADIAL_MENU}, {@link #V_MOUSE_COMPAT}, {@link #V_MOUSE_CURSOR}, +/// {@link #CONTAINER}, {@link #REGULAR_SCREEN}, {@link #ANY_SCREEN}, {@link #IN_GAME}. +/// Use these as anchors with {@link ActionLayerRegistry.Builder#above(Identifier)} and +/// {@link ActionLayerRegistry.Builder#below(Identifier)}. +public final class BuiltinActionLayers { + /// Always applicable. Transparent, at the top. + public static final Identifier UNKNOWN = CUtil.rl("unknown"); + /// A world is loaded. Overlay, at the bottom; hidden whenever a screen is open. + public static final Identifier IN_GAME = CUtil.rl("in_game"); + /// Any screen is open. Blocking, so gameplay contexts below it receive nothing. + public static final Identifier ANY_SCREEN = CUtil.rl("screen"); + /// A screen is open and the virtual mouse is off. Transparent. + public static final Identifier REGULAR_SCREEN = CUtil.rl("regular_screen"); + /// A container screen is open. Transparent. + public static final Identifier CONTAINER = CUtil.rl("container"); + /// The virtual mouse is on. Transparent. + public static final Identifier V_MOUSE_CURSOR = CUtil.rl("vmouse_cursor"); + /// The virtual mouse is on and emulating clicks. Transparent. + public static final Identifier V_MOUSE_COMPAT = CUtil.rl("vmouse_compat"); + /// The radial menu is open. Overlay, so its inputs shadow the screen contexts below. + public static final Identifier RADIAL_MENU = CUtil.rl("radial_menu"); + + private BuiltinActionLayers() { + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/BuiltinActions.java b/src/main/java/dev/isxander/controlify/api/action/BuiltinActions.java new file mode 100644 index 000000000..88ce7c390 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/BuiltinActions.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import dev.isxander.controlify.impl.action.ControlifyActions; + +/// A subset of Controlify's built-in actions that are API-safe; +/// meaning there is a guarantee that these will not be removed or renamed. +/// +/// If you require accessing none-API-safe actions, you can do so via [ControlifyActions]. +/// If you believe some actions should be API-safe when they are not here, request it via +/// a GitHub issue. +public final class BuiltinActions { + public static final ActionAccessor GUI_PRESS = ControlifyActions.GUI_PRESS; + public static final ActionAccessor GUI_BACK = ControlifyActions.GUI_BACK; + public static final ActionAccessor GUI_PREV_TAB = ControlifyActions.GUI_PREV_TAB; + public static final ActionAccessor GUI_NEXT_TAB = ControlifyActions.GUI_NEXT_TAB; + public static final ActionAccessor GUI_ABSTRACT_ACTION_1 = ControlifyActions.GUI_ABSTRACT_ACTION_1; + public static final ActionAccessor GUI_ABSTRACT_ACTION_2 = ControlifyActions.GUI_ABSTRACT_ACTION_2; + public static final ActionAccessor GUI_ABSTRACT_ACTION_3 = ControlifyActions.GUI_ABSTRACT_ACTION_3; + public static final ActionAccessor GUI_NAVI_UP = ControlifyActions.GUI_NAVI_UP; + public static final ActionAccessor GUI_NAVI_DOWN = ControlifyActions.GUI_NAVI_DOWN; + public static final ActionAccessor GUI_NAVI_LEFT = ControlifyActions.GUI_NAVI_LEFT; + public static final ActionAccessor GUI_NAVI_RIGHT = ControlifyActions.GUI_NAVI_RIGHT; + public static final ActionAccessor GUI_SECONDARY_NAVI_UP = ControlifyActions.GUI_SECONDARY_NAVI_UP; + public static final ActionAccessor GUI_SECONDARY_NAVI_DOWN = ControlifyActions.GUI_SECONDARY_NAVI_DOWN; + public static final ActionAccessor GUI_SECONDARY_NAVI_LEFT = ControlifyActions.GUI_SECONDARY_NAVI_LEFT; + public static final ActionAccessor GUI_SECONDARY_NAVI_RIGHT = ControlifyActions.GUI_SECONDARY_NAVI_RIGHT; + + private BuiltinActions() { + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/Channel.java b/src/main/java/dev/isxander/controlify/api/action/Channel.java new file mode 100644 index 000000000..4aaaae01f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/Channel.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +/// A channel is a way of interpreting the output state of a given {@link Action}. +/// An {@link Action action's} bindings sends output to any of these channels. +public sealed interface Channel { + /// A channel which gives a continuous float value, such as an axis/joystick. + non-sealed interface Continuous extends Channel { + float getContinuous(); + } + + /// A channel which gives single pulses, for example a button press causing a jump. + non-sealed interface Pulse extends Channel { + /// Consumes one available pulse + /// Typically you would use this with a `while` statement. + /// @return true if there was a pulse to consume + boolean consumePulse(); + + /// Consumes all available pulses + /// Typically you would use this with an `if` statement. + /// @return true if there was a pulse to consume + boolean consumeAllPulses(); + } + + /// A channel which gives a stateful digital output of the action, for example a physical switch's state. + non-sealed interface Latch extends Channel { + boolean isLatchActive(); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/Gesture.java b/src/main/java/dev/isxander/controlify/api/action/Gesture.java new file mode 100644 index 000000000..b009c6417 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/Gesture.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.action; + +import com.mojang.serialization.Codec; +import net.minecraft.util.StringRepresentable; + +import java.util.EnumSet; +import java.util.Set; + +/// What a binding's input must do to activate an action. +/// Instant gestures happen at a single moment and can only produce pulses. +/// Span gestures have a start and an end, so they can also hold a latch. +public enum Gesture implements StringRepresentable { + PRESS("press", Kind.INSTANT, Outcome.ALL, true), + RELEASE("release", Kind.INSTANT, Outcome.ALL, false), + TAP("tap", Kind.INSTANT, EnumSet.of(Outcome.LONE_TAP), false), + DOUBLE_TAP("double_tap", Kind.INSTANT, EnumSet.of(Outcome.DOUBLE_SECOND), false), + HOLD("hold", Kind.INSTANT, EnumSet.of(Outcome.HELD), true), + /// A release with no GUI navigation since the press. + GUI_PRESS("gui_press", Kind.INSTANT, Outcome.ALL, false), + + WHILE_PRESSED("while_pressed", Kind.SPAN, Outcome.ALL, true), + WHILE_HELD("while_held", Kind.SPAN, EnumSet.of(Outcome.HELD), true); + + public static final Codec CODEC = StringRepresentable.fromEnum(Gesture::values); + + private final String id; + private final Kind kind; + private final Set outcomes; + private final boolean firesBeforeRelease; + + Gesture(String id, Kind kind, Set outcomes, boolean firesBeforeRelease) { + this.id = id; + this.kind = kind; + this.outcomes = Set.copyOf(outcomes); + this.firesBeforeRelease = firesBeforeRelease; + } + + public Kind kind() { + return this.kind; + } + + /// The ways a single actuation can end that make this gesture fire. + /// Two gestures on the same input conflict when these overlap. + public Set outcomes() { + return this.outcomes; + } + + /// Whether this gesture can fire while the input is still held, before anything later + /// can cancel it. Such gestures still fire when their button is used as a chord modifier. + public boolean firesBeforeRelease() { + return this.firesBeforeRelease; + } + + @Override + public String getSerializedName() { + return this.id; + } + + public enum Kind { + INSTANT, + SPAN, + } + + /// The mutually exclusive ways a single press of an input can end. + /// Relies on the hold duration exceeding the tap duration. + public enum Outcome { + /// A short press with no second tap following it. + LONE_TAP, + /// The first half of a double tap. + DOUBLE_FIRST, + /// The second half of a double tap. + DOUBLE_SECOND, + /// Longer than a tap, released before the hold duration. + MEDIUM, + /// Reached the hold duration. + HELD; + + static final Set ALL = EnumSet.allOf(Outcome.class); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/action/package-info.java b/src/main/java/dev/isxander/controlify/api/action/package-info.java new file mode 100644 index 000000000..cbccfc406 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/action/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.action; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/bind/ControlifyBindApi.java b/src/main/java/dev/isxander/controlify/api/bind/ControlifyBindApi.java deleted file mode 100644 index 5fc639f63..000000000 --- a/src/main/java/dev/isxander/controlify/api/bind/ControlifyBindApi.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.bind; - -import dev.isxander.controlify.bindings.BindContext; -import dev.isxander.controlify.bindings.ControlifyBindApiImpl; -import dev.isxander.controlify.controller.ControllerEntity; -import net.minecraft.client.KeyMapping; -import net.minecraft.resources.Identifier; - -import java.util.List; -import java.util.function.Predicate; -import java.util.function.UnaryOperator; -import java.util.stream.Stream; - -/** - * The API for registering and managing bindings. - */ -public interface ControlifyBindApi { - static ControlifyBindApi get() { - return ControlifyBindApiImpl.INSTANCE; - } - - /** - * Registers a new binding that will be created on all controllers. - * - * @param callback the callback that populates the binding builder. will only be called once - * @return the supplier for the binding - */ - InputBindingSupplier registerBinding(RegistryCallback callback); - - /** - * Registers a new binding that will be created on only the controllers that pass the filter. - * - * @param callback the callback that populates the binding builder. will only be called once - * @param filter the filter that determines if the binding should be created for the controller - * @return the supplier for the binding - */ - InputBindingSupplier registerBinding(RegistryCallback callback, Predicate filter); - - /** - * Creates a supplier for an input binding with the given bind ID. - * Useful when fetching bindings by ID, rather than by java reference, such is needed - * in cases of data-driven systems. - * {@link InputBindingSupplier#on(ControllerEntity)} may throw an exception if the binding does not exist, - * when it is attempted to be resolved. - * @param bindId the ID of the binding to create a supplier for - * @return the supplier for the binding - */ - InputBindingSupplier createSupplier(Identifier bindId); - - /** - * Get all the controller bindings for a vanilla key mapping. - * This is populated using {@link InputBindingBuilder#addKeyCorrelation(KeyMapping)} - * or {@link InputBindingBuilder#keyEmulation(KeyMapping)}. - * - * @param key the key mapping to get the correlations for - * @return the list of the suppliers for the binding - */ - List getKeyCorrelation(KeyMapping key); - - /** - * Registers a new bind context that can be used to determine when a binding is active. - * - * @param context the bind context - */ - void registerBindContext(BindContext context); - - /** - * Gets all registered bind contexts. - * - * @return the stream of bind contexts - */ - Stream getAllBindIds(); - - @FunctionalInterface - interface RegistryCallback extends UnaryOperator { - } -} diff --git a/src/main/java/dev/isxander/controlify/api/bind/InputBinding.java b/src/main/java/dev/isxander/controlify/api/bind/InputBinding.java deleted file mode 100644 index 1d86013dd..000000000 --- a/src/main/java/dev/isxander/controlify/api/bind/InputBinding.java +++ /dev/null @@ -1,243 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.bind; - -import dev.isxander.controlify.bindings.BindContext; -import dev.isxander.controlify.bindings.StateAccess; -import dev.isxander.controlify.bindings.input.EmptyInput; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.bindings.output.AnalogueOutput; -import dev.isxander.controlify.bindings.output.DigitalOutput; -import dev.isxander.controlify.bindings.output.GuiPressOutput; -import dev.isxander.controlify.controller.input.ControllerStateView; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.ApiStatus; - -import java.util.Set; -import java.util.function.Consumer; - -/** - * An input binding is a single action that can be performed in Minecraft, e.g. punching. - * It is bound to a controller {@link Input}, e.g. the A button. - * Every tick, the input binding will update its state based on the controller input and then propagate to its outputs, - * e.g. {@link #justTapped()}. The outputs are then used to perform the action in the game. - */ -public interface InputBinding { - /** - * The ID of the binding, set from {@link InputBindingBuilder#id(Identifier)}. - * @return the ID - */ - Identifier id(); - - /** - * The name of the binding. - * @return the name - */ - Component name(); - - /** - * The description of the binding. - * @return the description - */ - Component description(); - - /** - * The category of the binding. - * @return the category - */ - Component category(); - - /** - * The icon glyph of the input - * @return the icon component - */ - Component inputGlyph(); - - /** - * The icon glyph of the input - * @return the icon component - */ - @Deprecated - default Component inputIcon() { - return this.inputGlyph(); - } - - @Deprecated(forRemoval = true) - boolean isRadialCandidate(); - - /** - * Create a new state access with the given history required. - * Please use the minimum amount of history required. - * - * @param historyRequired the amount of ticks of history required. - * @return the state access - */ - StateAccess createStateAccess(int historyRequired); - - /** - * Create a new state access with the given history required and push event. - * Please use the minimum amount of history required. - * - * @param historyRequired the amount of ticks of history required. - * @param pushEvent the event to call when a new state is pushed. - * @return the state access - */ - StateAccess createStateAccess(int historyRequired, Consumer pushEvent); - - /** - * Returns the state access created with {@link #createStateAccess(int)}. - * This renders it unusable forever. - * This frees up history if possible. - * - * @param stateAccess the state access to return - */ - void returnStateAccess(StateAccess stateAccess); - - @ApiStatus.Internal - void pushState(ControllerStateView state); - - /** - * Emulates a tap of this binding. - * This is used by the radial menu. - */ - void fakePress(); - - /** - * Set the input that this binding is bound to. - * - * @param input input to bind - */ - void setBoundInput(Input input); - - /** - * Get the input that this binding is bound to. - * - * @return currently bound input - */ - Input boundInput(); - - /** - * Get the default input for this binding. - * This can change if the user reloads resources and the default changes - * (since it's data-driven). - * - * @return default input - */ - Input defaultInput(); - - /** - * @return true if the binding is equal to {@link EmptyInput} - */ - default boolean isUnbound() { - return EmptyInput.equals(this.boundInput()); - } - - /** - * Gets all contexts associated with this binding, - * not just the currently applicable ones. - * This is set from {@link InputBindingBuilder#allowedContexts(BindContext...)}. - * - * @return the contexts - */ - Set contexts(); - - /** - * Equivalent to calling - *


-	 *     StateAccess stateAccess = binding.createStateAccess(0);
-	 *     float analogueNow = stateAccess.analogue(0);
-	 * 
- * - * @return the current analogue state (0-1, never negative), - * this tick. - */ - float analogueNow(); - - /** - * Equivalent to calling - *

-	 *     StateAccess stateAccess = binding.createStateAccess(1);
-	 *     float analoguePrev = stateAccess.analogue(1);
-	 * 
- * - * @return the previous analogue state (0-1, never negative), 1 tick ago - */ - float analoguePrev(); - - /** - * Equivalent to calling - *

-	 *     StateAccess stateAccess = binding.createStateAccess(0);
-	 *     boolean digitalNow = stateAccess.digital(0);
-	 * 
- * - * @return the current digital state (true or false), this tick - */ - boolean digitalNow(); - - /** - * Equivalent to calling - *

-	 *     StateAccess stateAccess = binding.createStateAccess(1);
-	 *     boolean digitalPrev = stateAccess.digital(1);
-	 * 
- * - * @return the previous digital state (true or false), 1 tick ago - */ - boolean digitalPrev(); - - /** - * Equivalent to calling - *

-	 *     binding.digitalNow() && !binding.digitalPrev()
-	 * 
- * - * @return true if the binding is pressed this tick and not pressed the previous tick - */ - boolean justPressed(); - - /** - * Equivalent to calling - *

-	 *     !binding.digitalNow() && binding.digitalPrev()
-	 * 
- * - * @return true if the binding is not pressed this tick and pressed the previous tick - */ - boolean justReleased(); - - /** - * @return true if the binding got pressed, and then released - */ - boolean justTapped(); - - /** - * A more advanced output that returns true after the input was released, - * but only if the player has not navigated away from the button where the input was initially pressed. - * - * @return the output - */ - GuiPressOutput guiPressed(); - - Identifier ANALOGUE_NOW = CUtil.rl("analogue_now"); - Identifier ANALOGUE_PREV = CUtil.rl("analogue_prev"); - Identifier DIGITAL_NOW = CUtil.rl("digital_now"); - Identifier DIGITAL_PREV = CUtil.rl("digital_prev"); - Identifier JUST_PRESSED = CUtil.rl("just_pressed"); - Identifier JUST_RELEASED = CUtil.rl("just_released"); - Identifier JUST_TAPPED = CUtil.rl("just_tapped"); - Identifier GUI_PRESSED = CUtil.rl("gui_pressed"); - Identifier KEY_EMULATION = CUtil.rl("key_emulation"); - - T getDigitalOutput(Identifier id); - T addDigitalOutput(Identifier id, T output); - - T getAnalogueOutput(Identifier id); - T addAnalogueOutput(Identifier id, T output); -} diff --git a/src/main/java/dev/isxander/controlify/api/bind/InputBindingBuilder.java b/src/main/java/dev/isxander/controlify/api/bind/InputBindingBuilder.java deleted file mode 100644 index e0671897d..000000000 --- a/src/main/java/dev/isxander/controlify/api/bind/InputBindingBuilder.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.bind; - -import dev.isxander.controlify.bindings.BindContext; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.controller.ControllerEntity; -import net.minecraft.client.KeyMapping; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.function.Function; - -/** - * A builder for creating an {@link InputBinding}. - * This is used to populate the fields of the binding. - * The builder is provided through a callback in - * {@link ControlifyBindApi#registerBinding(ControlifyBindApi.RegistryCallback)} - * and should not be created or built manually. - */ -public interface InputBindingBuilder { - /** - * Sets the unique identifier for the binding. - * This is a required field. The namespace should be your mod's ID. - *

- * The id is used to automatically generate the translation keys for the name and description. - *

    - *
  • Name: controlify.binding.(namespace).(path)
  • - *
  • Desc: controlify.binding.(namespace).(path).desc, optional
  • - *
- * This is the ID referred to in {@link InputBindingSupplier#bindId()}. - * - * @param rl the binding's id - * @return this builder - */ - InputBindingBuilder id(@NotNull Identifier rl); - - /** - * Sets the unique identifier for the binding. - * This is a required field. The namespace should be your mod's ID. - *

- * The id is used to automatically generate the translation keys for the name and description. - *

    - *
  • Name: controlify.binding.(namespace).(path)
  • - *
  • Desc: controlify.binding.(namespace).(path).desc, optional
  • - *
- * This is the ID referred to in {@link InputBindingSupplier#bindId()}. - * - * @param namespace namespace of {@link Identifier} - * @param path path of {@link Identifier} - * @return this builder - */ - InputBindingBuilder id(@NotNull String namespace, @NotNull String path); - - /** - * Sets a custom name component for the binding. Optional field. - * If not set, the name will be automatically generated from {@link #id(Identifier)}, - * controlify.binding.(namespace).(path) - *

- * Useful for re-using language keys from regular key mappings. - * - * @param text the name component - * @return this builder - */ - InputBindingBuilder name(@NotNull Component text); - - /** - * Sets a custom description component for the binding. Optional field. - * If not set, the description will be automatically generated from {@link #id(Identifier)}, - * controlify.binding.(namespace).(path).desc - * The description will be empty if the translation key does not exist. - *

- * Useful for re-using language keys from regular key mappings. - * - * @param text the name component - * @return this builder - */ - InputBindingBuilder description(@NotNull Component text); - - /** - * Sets the category for the binding. Required field. - * This is used to group the binding in the option menu. - * It's recommended that you use a single category for all of your mod's bindings. - * - * @param text the category component - * @return this builder - */ - InputBindingBuilder category(@NotNull Component text); - - /** - * Sets the contexts that the binding is allowed to be active in. - * If the binding's predicate {@link BindContext#isApplicable()} returns false, all outputs will be disabled. - * - * @param contexts all applicable contexts - * @return this builder - */ - InputBindingBuilder allowedContexts(@NotNull BindContext @Nullable... contexts); - - /** - * Adds a correlation between a vanilla key mapping and this binding. - * A correlation means that an auto-generated binding for this vanilla key mapping will not be created. - * - * @param keyMapping the key mapping to correlate with - * @return this builder - */ - InputBindingBuilder addKeyCorrelation(@NotNull KeyMapping keyMapping); - - /** - * Makes the binding emulate a vanilla key mapping. - * If the bound input is pressed, so will the key mapping. - * - * @param keyMapping the key mapping to emulate - * @return this builder - */ - InputBindingBuilder keyEmulation(@NotNull KeyMapping keyMapping); - - InputBindingBuilder keyEmulation(@NotNull KeyMapping keyMapping, @Nullable Function toggleCondition); - - /** - * Sets the default input for the binding. - * It is not recommended to use this method. Default inputs should be data-driven. - * If specified, the default will be bottom-most layered with data-driven defaults. - * To set the default input from data, specify it in: - *

assets/controlify/controllers/default_bind/default.json
- *

-	 *     {
-	 *         "defaults": {
-	 *             "your_binding_id": {
-	 *                 "button": "controlify:button/south"
-	 *             }
-	 *         }
-	 *     }
-	 * 
- * @param input the default input - * @return this builder - */ - @Deprecated - InputBindingBuilder defaultInput(@Nullable Input input); - - /// @deprecated Radial Menu candidates are now data driven. - /// [Check out the wiki](https://moddedmc.wiki/en/project/controlify/latest/docs/resource-packs/radial-icons) - @Deprecated(forRemoval = true) - default InputBindingBuilder radialCandidate(@Nullable Identifier icon) { - return radialCandidate(icon != null); - } - - @Deprecated(forRemoval = true) - InputBindingBuilder radialCandidate(boolean isCandidate); -} diff --git a/src/main/java/dev/isxander/controlify/api/bind/InputBindingSupplier.java b/src/main/java/dev/isxander/controlify/api/bind/InputBindingSupplier.java deleted file mode 100644 index 50cc4fdd5..000000000 --- a/src/main/java/dev/isxander/controlify/api/bind/InputBindingSupplier.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.bind; - -import com.mojang.serialization.Codec; -import dev.isxander.controlify.controller.ControllerEntity; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.Objects; - -/** - * A supplier that retrieves an {@link InputBinding} for a controller. - */ -public interface InputBindingSupplier { - /** - * Retrieves the input binding for the controller, throwing an exception if it does not exist. - * - * @param controller the controller to fetch the binding from - * @throws NullPointerException if the binding does not exist - * @return the input binding - */ - default InputBinding on(@NotNull ControllerEntity controller) { - return Objects.requireNonNull( - onOrNull(controller), - () -> "Attempted to fetch " + bindId() + " for controller " + controller.uid() + " but it did not exist." + - "The binding registry callback may have a filter that did not pass for this controller."); - } - - /** - * Retrieves the input binding for the controller, or null if it does not exist. - * - * @param controller controller to fetch binding from - * @return the input binding, or null if it does not exist - */ - @Nullable InputBinding onOrNull(ControllerEntity controller); - - /** - * @return id of the binding. - */ - Identifier bindId(); - - /** - * Returns a text component with the currently bound input glyph - * for the currently selected controller. - *

- * Be careful, as this component will be rendered differently - * depending on the active controller, so make sure to deal with - * width caching and other issues that may arise. - * @see InputBinding#inputGlyph() for a deterministic version on a specific controller - * @return a component representing the input glyph - */ - Component inputGlyph(); - - Codec CODEC = Identifier.CODEC.xmap( - bindId -> ControlifyBindApi.get().createSupplier(bindId), - InputBindingSupplier::bindId - ); -} diff --git a/src/main/java/dev/isxander/controlify/api/bind/RadialIcon.java b/src/main/java/dev/isxander/controlify/api/bind/RadialIcon.java deleted file mode 100644 index 101c41f9a..000000000 --- a/src/main/java/dev/isxander/controlify/api/bind/RadialIcon.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.bind; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.DataResult; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; - -import java.util.Optional; - -public record RadialIcon(Content content, @Nullable Component overlay) { - public static final RadialIcon EMPTY = new RadialIcon(new Empty(), null); - - public static final Codec CODEC = Definition.CODEC.flatXmap( - Definition::toRadialIcon, - RadialIcon::toDefinition - ); - - public static RadialIcon model(Identifier model) { - return new RadialIcon(new Model(model), null); - } - - public static RadialIcon texture(Identifier texture) { - return new RadialIcon(new Texture(texture), null); - } - - public RadialIcon withOverlay(Component overlay) { - return new RadialIcon(content, overlay); - } - - private static DataResult toDefinition(RadialIcon icon) { - return switch (icon.content()) { - case Model(Identifier model) -> DataResult.success(new Definition(Optional.of(model), Optional.empty())); - case Texture(Identifier texture) -> DataResult.success(new Definition(Optional.empty(), Optional.of(texture))); - default -> DataResult.error(() -> "Only model and texture radial icons can be serialized"); - }; - } - - public sealed interface Content permits Empty, Model, Texture { - } - - public record Empty() implements Content { - } - - public record Model(Identifier id) implements Content { - } - - public record Texture(Identifier id) implements Content { - } - - private record Definition(Optional model, Optional texture) { - private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.optionalFieldOf("model").forGetter(Definition::model), - Identifier.CODEC.optionalFieldOf("texture").forGetter(Definition::texture) - ).apply(instance, Definition::new)); - - private DataResult toRadialIcon() { - if (model.isPresent() == texture.isPresent()) { - return DataResult.error(() -> "Radial icon must define exactly one of 'model' or 'texture'"); - } - - return DataResult.success(model - .map(RadialIcon::model) - .orElseGet(() -> RadialIcon.texture(texture.orElseThrow()))); - } - } -} diff --git a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java b/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java deleted file mode 100644 index ab2bca95c..000000000 --- a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuideApi.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.buttonguide; - -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.gui.ButtonGuideRenderer; -import net.minecraft.client.gui.screens.Screen; -import org.jetbrains.annotations.NotNull; - -import java.util.function.Supplier; - -/** - * Adds a guide to a button. This does not invoke the button press on binding trigger, only renders the guide. - * This should be called every time a button is initialised, like in {@link Screen#init()} - */ -public final class ButtonGuideApi { - - private ButtonGuideApi() { - } - - /** - * Makes the button render the image of the binding specified. - * This does not invoke the button press on binding trigger, only renders the guide. - * Custom behaviour should be handled inside a {@link dev.isxander.controlify.screenop.ScreenProcessor} or {@link dev.isxander.controlify.screenop.ComponentProcessor} - * - * @param button button to render the guide for - * @param binding the custom binding to render - * @param renderPredicate whether the guide should be rendered - */ - public static void addGuideToButton( - @NotNull T button, - @NotNull InputBindingSupplier binding, - @NotNull ButtonGuidePredicate renderPredicate) { - ButtonGuideRenderer.registerBindingForButton(button, () -> binding, renderPredicate); - } - - public static void addGuideToButton( - @NotNull T button, - @NotNull Supplier binding, - @NotNull ButtonGuidePredicate renderPredicate) { - ButtonGuideRenderer.registerBindingForButton(button, binding, renderPredicate); - } -} diff --git a/src/main/java/dev/isxander/controlify/api/contextual/BuiltinContextualDomains.java b/src/main/java/dev/isxander/controlify/api/contextual/BuiltinContextualDomains.java new file mode 100644 index 000000000..d900445d3 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/BuiltinContextualDomains.java @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +/// Access to Controlify's built-in domains during registration and throughout runtime. +public interface BuiltinContextualDomains { + ContextualDomain inGame(); + + ContextualDomain container(); +} diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContainerContext.java b/src/main/java/dev/isxander/controlify/api/contextual/ContainerContext.java index 7f6aba855..bbc37c83d 100644 --- a/src/main/java/dev/isxander/controlify/api/contextual/ContainerContext.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContainerContext.java @@ -6,7 +6,7 @@ */ package dev.isxander.controlify.api.contextual; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.api.controller.Controller; import net.minecraft.world.entity.player.Player; import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; @@ -17,7 +17,7 @@ public record ContainerContext( @Nullable Slot hoveredSlot, ItemStack holdingItem, boolean cursorOutsideContainer, - ControllerEntity controller, + Controller controller, GuideVerbosity verbosity ) implements Context { } diff --git a/src/main/java/dev/isxander/controlify/api/contextual/Context.java b/src/main/java/dev/isxander/controlify/api/contextual/Context.java index e77d7598f..c25b54667 100644 --- a/src/main/java/dev/isxander/controlify/api/contextual/Context.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/Context.java @@ -6,10 +6,10 @@ */ package dev.isxander.controlify.api.contextual; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.api.controller.Controller; public interface Context { - ControllerEntity controller(); + Controller controller(); GuideVerbosity verbosity(); } diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomainRegistry.java b/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomainRegistry.java index 048bd6c36..0b1f6efb2 100644 --- a/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomainRegistry.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContextualDomainRegistry.java @@ -8,11 +8,11 @@ import net.minecraft.resources.Identifier; -public interface ContextualDomainRegistry { - ContextualDomain inGame(); - - ContextualDomain container(); - +/// Supplied during ContextualEvents.REGISTER_DOMAINS for registering custom domains +/// and accessing built-in domains, for example to register contributors. +public interface ContextualDomainRegistry extends BuiltinContextualDomains { + /// Register during ContextualEvents.REGISTER_DOMAINS, before initial resource loading. + /// Throws when registration is closed or the domain identifier is already registered. ContextualDomain register(Identifier domainId); default ContextualDomain register(Identifier domainId, ContextualStateContributor contributor) { diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContextualEvents.java b/src/main/java/dev/isxander/controlify/api/contextual/ContextualEvents.java new file mode 100644 index 000000000..ec98eedc4 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContextualEvents.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.contextual; + +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.contextual.ContextualDomains; + +public final class ContextualEvents { + /// Fires once during early setup, before initial resource loading. Register domains + /// here so their resource listeners join the first reload. New-domain registration + /// closes afterwards; the registry and existing domains may be retained. + public static final EventSource REGISTER_DOMAINS = ContextualDomains.Events.REGISTER_DOMAINS; + + private ContextualEvents() {} +} diff --git a/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateContributor.java b/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateContributor.java index 5b0df668b..6ef0b994a 100644 --- a/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateContributor.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/ContextualStateContributor.java @@ -14,7 +14,7 @@ default ContextualStateContributor andThen(ContextualStateContributor ContextualStateContributor noop() { - return (context, sink) -> {}; + return (_, _) -> {}; } @SafeVarargs diff --git a/src/main/java/dev/isxander/controlify/api/contextual/GuideVerbosity.java b/src/main/java/dev/isxander/controlify/api/contextual/GuideVerbosity.java index 8baba51d3..1aeb626f3 100644 --- a/src/main/java/dev/isxander/controlify/api/contextual/GuideVerbosity.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/GuideVerbosity.java @@ -10,7 +10,6 @@ import dev.isxander.yacl3.api.NameableEnum; import net.minecraft.network.chat.Component; import net.minecraft.util.StringRepresentable; -import org.jspecify.annotations.NonNull; public enum GuideVerbosity implements NameableEnum, StringRepresentable { FULL(3), @@ -35,7 +34,7 @@ public Component getDisplayName() { } @Override - public @NonNull String getSerializedName() { + public String getSerializedName() { return name().toLowerCase(); } } diff --git a/src/main/java/dev/isxander/controlify/api/contextual/InGameContext.java b/src/main/java/dev/isxander/controlify/api/contextual/InGameContext.java index bfcf9a0d4..e809c7692 100644 --- a/src/main/java/dev/isxander/controlify/api/contextual/InGameContext.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/InGameContext.java @@ -6,7 +6,8 @@ */ package dev.isxander.controlify.api.contextual; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.impl.controller.ControllerImpl; import dev.isxander.controlify.mixins.feature.guide.ingame.MinecraftAccessor; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientLevel; @@ -21,11 +22,11 @@ public record InGameContext( LocalPlayer player, ClientLevel level, HitResult hitResult, - ControllerEntity controller, + Controller controller, GuideVerbosity verbosity ) implements Context { @ApiStatus.Internal - public static InGameContext create(Minecraft minecraft, ControllerEntity controller) { + public static InGameContext create(Minecraft minecraft, Controller controller) { var hitResult = minecraft.hitResult; if (hitResult == null) { ((MinecraftAccessor) minecraft).controlify$invokePick(1f); @@ -38,7 +39,7 @@ public static InGameContext create(Minecraft minecraft, ControllerEntity control Objects.requireNonNull(minecraft.level, "level"), hitResult, controller, - controller.settings().generic.guide.verbosity + ((ControllerImpl) controller).settings().generic.guide.verbosity ); } } diff --git a/src/main/java/dev/isxander/controlify/api/contextual/TriggerEffectInstance.java b/src/main/java/dev/isxander/controlify/api/contextual/TriggerEffectInstance.java index 52551b404..673af410d 100644 --- a/src/main/java/dev/isxander/controlify/api/contextual/TriggerEffectInstance.java +++ b/src/main/java/dev/isxander/controlify/api/contextual/TriggerEffectInstance.java @@ -6,7 +6,7 @@ */ package dev.isxander.controlify.api.contextual; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseTriggerEffect; import org.jetbrains.annotations.ApiStatus; public interface TriggerEffectInstance extends ContextualInstance { diff --git a/src/main/java/dev/isxander/controlify/api/contextual/package-info.java b/src/main/java/dev/isxander/controlify/api/contextual/package-info.java new file mode 100644 index 000000000..ea2ec8aa8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/contextual/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.contextual; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/controller/Controller.java b/src/main/java/dev/isxander/controlify/api/controller/Controller.java new file mode 100644 index 000000000..93c32ffa6 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/Controller.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller; + +import dev.isxander.controlify.api.controller.dualsense.DualsenseComponent; +import dev.isxander.controlify.api.controller.rumble.RumbleComponent; +import dev.isxander.controlify.api.controller.input.InputComponent; +import dev.isxander.controlify.api.controller.gyro.GyroComponent; +import net.minecraft.resources.Identifier; + +import java.util.Optional; + +/// The API-safe controller instance. +public interface Controller { + /// The unique identifier of this controller. + /// The same physical controller should have the same + /// identifier after being unplugged and replugged. + String uid(); + + /// The friendly name of this controller. + /// + /// This may use {@link driverReportedName}, but the + /// controller's type may override it, or the user may override + /// it. + /// + /// Because the user may override this, it is not a + /// guarantee that this name is constant for the lifetime + /// of this controller object. + String name(); + + /// The friendly name of this controller reported by + /// the underlying controller driver. + /// + /// To get the display name of this controller, you + /// should use {@link name}. + String driverReportedName(); + + /// The identifier of this controller's type. + /// This is the same ID defined by `controller_identification.json5`. + Identifier typeId(); + + /// Input capabilities and current actions. + InputComponent input(); + + /// Present if this controller supports gyroscope input. + Optional gyro(); + + /// Get the rumble component that may be attached to this controller. + /// If the returned optional is empty, then the controller is not capable of rumble. + Optional rumble(); + + /// Present if this controller is a dualsense controller. + /// You can configure the mute light. + Optional dualsense(); +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/dualsense/DualsenseComponent.java b/src/main/java/dev/isxander/controlify/api/controller/dualsense/DualsenseComponent.java new file mode 100644 index 000000000..1f5276a6e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/dualsense/DualsenseComponent.java @@ -0,0 +1,12 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.dualsense; + +public interface DualsenseComponent { + /// Set the state of the controller's mute LED to either lit or unlit + void setMicLightMode(MicLightMode mode); +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/dualsense/MicLightMode.java b/src/main/java/dev/isxander/controlify/api/controller/dualsense/MicLightMode.java new file mode 100644 index 000000000..ede46aa71 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/dualsense/MicLightMode.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.dualsense; + +public enum MicLightMode { + OFF, + ON, + BREATHING; + + public static MicLightMode fromBoolean(boolean on) { + return on ? ON : OFF; + } +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/dualsense/package-info.java b/src/main/java/dev/isxander/controlify/api/controller/dualsense/package-info.java new file mode 100644 index 000000000..75c91c2ea --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/dualsense/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.controller.dualsense; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/controller/gyro/GyroComponent.java b/src/main/java/dev/isxander/controlify/api/controller/gyro/GyroComponent.java new file mode 100644 index 000000000..4e4e238ea --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/gyro/GyroComponent.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.gyro; + +import org.joml.Vector3fc; + +/// Gyroscope input. Presence in Controller.gyro() indicates gyro support. +public interface GyroComponent { + /// The latest calibrated, filtered and integrated gyro interval in radians. + /// Returns zero when the latest interval is older than 50ms. Read-only; + /// consumers do not need to participate in Controlify's internal update lifecycle. + Vector3fc getState(); +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/gyro/package-info.java b/src/main/java/dev/isxander/controlify/api/controller/gyro/package-info.java new file mode 100644 index 000000000..67dc2544f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/gyro/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.controller.gyro; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/controller/input/InputComponent.java b/src/main/java/dev/isxander/controlify/api/controller/input/InputComponent.java new file mode 100644 index 000000000..983f55015 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/input/InputComponent.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.input; + +import dev.isxander.controlify.api.action.Action; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +/// Public input capabilities and current actions of a controller. +public interface InputComponent { + /// Null when unavailable. Resolve again after profile changes, which can replace actions. + @Nullable Action getAction(Identifier actionId); + + boolean isGamepad(); +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/input/package-info.java b/src/main/java/dev/isxander/controlify/api/controller/input/package-info.java new file mode 100644 index 000000000..c52303e6f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/input/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.controller.input; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/controller/package-info.java b/src/main/java/dev/isxander/controlify/api/controller/package-info.java new file mode 100644 index 000000000..a6310cc72 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.controller; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/controller/rumble/ContinuousRumbleEffect.java b/src/main/java/dev/isxander/controlify/api/controller/rumble/ContinuousRumbleEffect.java new file mode 100644 index 000000000..377b81b3f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/rumble/ContinuousRumbleEffect.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.rumble; + +import net.minecraft.world.phys.Vec3; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.function.IntFunction; +import java.util.function.Supplier; + +/// An immutable effect definition with independent timing for every playback. +public non-sealed interface ContinuousRumbleEffect extends RumbleEffect { + @Override ContinuousRumbleEffect stopCondition(BooleanSupplier condition); + + ContinuousRumbleEffect byTick(IntFunction state); + + ContinuousRumbleEffect constant(float strong, float weak); + + /// Sets the inactivity timeout. Zero disables it; keepAlive resets its clock. + ContinuousRumbleEffect timeout(int ticks); + int timeoutTicks(); + + /// Sets the minimum duration before timeout or a stop condition can finish playback. + /// Explicitly stopping the playback handle always takes effect immediately. + ContinuousRumbleEffect minTime(int ticks); + int minimumTicks(); + + /// Applies distance attenuation relative to the camera, using squared distance / range². + /// Playback ends when no camera is available (subject to the minimum duration). + ContinuousRumbleEffect inWorld( + Supplier location, + float minMagnitude, float maxMagnitude, + float range, + Function falloff + ); +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/rumble/FiniteRumbleEffect.java b/src/main/java/dev/isxander/controlify/api/controller/rumble/FiniteRumbleEffect.java new file mode 100644 index 000000000..e69e12e3f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/rumble/FiniteRumbleEffect.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.rumble; + +import java.util.Objects; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.function.IntFunction; + +/// An immutable, finite effect definition. Each configuration method returns a copy. +public non-sealed interface FiniteRumbleEffect extends RumbleEffect { + @Override + FiniteRumbleEffect stopCondition(BooleanSupplier condition); + + int durationTicks(); + + /// Replaces the waveform with one sample for each zero-based tick. + FiniteRumbleEffect byTick(IntFunction state, int durationTicks); + + /// Replaces the waveform, sampling time in [0, 1), as tick / duration. + FiniteRumbleEffect byTime(Function state, int durationTicks); + + FiniteRumbleEffect constant(float strong, float weak, int durationTicks); + + /// Appends another waveform, retaining this effect's stop conditions. + FiniteRumbleEffect then(FiniteRumbleEffect next); + + FiniteRumbleEffect repeat(int count); +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleComponent.java b/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleComponent.java new file mode 100644 index 000000000..8abb94df6 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleComponent.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.rumble; + +import net.minecraft.resources.Identifier; + +public interface RumbleComponent { + /// Queues independent playback of a reusable effect definition for the next rumble tick. + /// Stopping the returned handle affects only this playback. + /// The effect ID identifies the event causing the rumble, allowing resource packs + /// to assign its rumble source independently of the waveform. + RumbleEffectInstance playEffect(Identifier effectId, RumbleEffect effect); +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleEffect.java b/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleEffect.java new file mode 100644 index 000000000..539d30ed8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleEffect.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.rumble; + +import dev.isxander.controlify.impl.controller.rumble.FiniteRumbleEffectImpl; +import dev.isxander.controlify.impl.controller.rumble.ContinuousRumbleEffectImpl; + +import java.util.function.BooleanSupplier; + +/// A reusable effect definition. Configuration methods return new definitions; +/// playback state is held by the instance returned from RumbleComponent.playEffect. +/// Callbacks may be shared by multiple playbacks and are invoked on the client thread. +public sealed interface RumbleEffect permits FiniteRumbleEffect, ContinuousRumbleEffect { + /// Samples this definition at a zero-based playback tick. + RumbleState stateAt(int tick); + + /// Returns whether playback should end before its next sample. + boolean shouldStop(); + + /// Returns a copy with an additional stop condition. Conditions are ORed. + RumbleEffect stopCondition(BooleanSupplier condition); + + static FiniteRumbleEffect finite() { + return new FiniteRumbleEffectImpl(); + } + + static ContinuousRumbleEffect continuous() { + return new ContinuousRumbleEffectImpl(); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleEffectInstance.java b/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleEffectInstance.java new file mode 100644 index 000000000..eaba4c0da --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleEffectInstance.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controller.rumble; + +/// A handle on a specific instance of a queued, playing, or played rumble +/// effect. +public interface RumbleEffectInstance { + /// @return true if the queued rumble effect is playing, or has finished playing + boolean hasStarted(); + + /// @return true if queued rumble effect has finished playing. + boolean hasFinished(); + + /// Stops the effect before it has finished. + /// + /// @return true if the effect was stopped as a result of this method invocation. + boolean stop(); + + /// Resets the age of this effect instance. + /// Only applicable for continuous effects, it prevents + /// them from timing out. This should be called frequently in the + /// context that the effect should stay viable. + /// If the effect has already finished playing, the effect cannot be kept + /// alive as it is already dead. + /// + /// @return true if the effect was successfully kept alive + boolean keepAlive(); +} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleState.java b/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleState.java similarity index 94% rename from src/main/java/dev/isxander/controlify/rumble/RumbleState.java rename to src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleState.java index a9e2d7672..8268caabc 100644 --- a/src/main/java/dev/isxander/controlify/rumble/RumbleState.java +++ b/src/main/java/dev/isxander/controlify/api/controller/rumble/RumbleState.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.rumble; +package dev.isxander.controlify.api.controller.rumble; import com.mojang.serialization.Codec; diff --git a/src/main/java/dev/isxander/controlify/rumble/TriggerRumbleState.java b/src/main/java/dev/isxander/controlify/api/controller/rumble/TriggerRumbleState.java similarity index 89% rename from src/main/java/dev/isxander/controlify/rumble/TriggerRumbleState.java rename to src/main/java/dev/isxander/controlify/api/controller/rumble/TriggerRumbleState.java index d09650f78..236db1ad5 100644 --- a/src/main/java/dev/isxander/controlify/rumble/TriggerRumbleState.java +++ b/src/main/java/dev/isxander/controlify/api/controller/rumble/TriggerRumbleState.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.rumble; +package dev.isxander.controlify.api.controller.rumble; public record TriggerRumbleState(float left, float right) { public static final TriggerRumbleState NONE = new TriggerRumbleState(0.0F, 0.0F); diff --git a/src/main/java/dev/isxander/controlify/api/controller/rumble/package-info.java b/src/main/java/dev/isxander/controlify/api/controller/rumble/package-info.java new file mode 100644 index 000000000..db9e76381 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controller/rumble/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.controller.rumble; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/controllermanager/ControllerEvents.java b/src/main/java/dev/isxander/controlify/api/controllermanager/ControllerEvents.java new file mode 100644 index 000000000..2508b1e30 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controllermanager/ControllerEvents.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.controllermanager; + +import dev.isxander.controlify.api.ControlifyApi; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.controllermanager.ControllerCoordinator; +import org.jspecify.annotations.Nullable; + +public final class ControllerEvents { + /// Fires once after initial discovery, selection and config saving, including an empty + /// result. Does not fire if the backend failed to initialize. + public static final EventSource DISCOVERY_COMPLETED = ControllerCoordinator.Events.DISCOVERY_COMPLETED; + + /// Fires after insertion and internal connection setup. Selection notifications may fire first. + public static final EventSource CONTROLLER_CONNECTED = ControllerCoordinator.Events.CONTROLLER_CONNECTED; + + /// Fires after removal and selection updates, before closing the controller. It is + /// absent from queries and must not be used after the callback returns. + public static final EventSource CONTROLLER_DISCONNECTED = ControllerCoordinator.Events.CONTROLLER_DISCONNECTED; + + /// Fires after activation, input-mode and handler updates. Null means selection was cleared. + public static final EventSource CURRENT_CONTROLLER_CHANGED = ControllerCoordinator.Events.CURRENT_CONTROLLER_CHANGED; + + public record ControllerConnected(ControlifyApi controlify, Controller controller, boolean hotplugged) {} + public record ControllerDisconnected(ControlifyApi controlify, Controller controller) {} + public record CurrentControllerChanged(ControlifyApi controlify, @Nullable Controller controller) {} + + private ControllerEvents() {} +} diff --git a/src/main/java/dev/isxander/controlify/api/controllermanager/package-info.java b/src/main/java/dev/isxander/controlify/api/controllermanager/package-info.java new file mode 100644 index 000000000..e49573ec9 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/controllermanager/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.controllermanager; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/entrypoint/ControlifyEntrypoint.java b/src/main/java/dev/isxander/controlify/api/entrypoint/ControlifyEntrypoint.java deleted file mode 100644 index da3c7507c..000000000 --- a/src/main/java/dev/isxander/controlify/api/entrypoint/ControlifyEntrypoint.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.entrypoint; - -import dev.isxander.controlify.api.ControlifyApi; - -public interface ControlifyEntrypoint { - /** - * Called once Controlify has been fully initialised. And all controllers have - * been discovered and loaded. - * Due to the nature of the resource-pack system, this is called - * very late in the game's lifecycle (once the resources have been reloaded). - */ - void onControllersDiscovered(ControlifyApi controlify); - - /** - * Called once Controlify has initialised some systems but controllers - * have not yet been discovered and constructed. This is the ideal - * time to register events in preparation for controller discovery. - */ - void onControlifyInit(InitContext context); - - - /** - * Called at the end of Controlify's client-side entrypoint. - * You can register guides here. - */ - void onControlifyPreInit(PreInitContext context); - -} diff --git a/src/main/java/dev/isxander/controlify/api/entrypoint/InitContext.java b/src/main/java/dev/isxander/controlify/api/entrypoint/InitContext.java deleted file mode 100644 index bbeaa8817..000000000 --- a/src/main/java/dev/isxander/controlify/api/entrypoint/InitContext.java +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.entrypoint; - -import dev.isxander.controlify.api.ControlifyApi; - -public interface InitContext { - - ControlifyApi controlify(); -} diff --git a/src/main/java/dev/isxander/controlify/api/entrypoint/PreInitContext.java b/src/main/java/dev/isxander/controlify/api/entrypoint/PreInitContext.java deleted file mode 100644 index af0a053a5..000000000 --- a/src/main/java/dev/isxander/controlify/api/entrypoint/PreInitContext.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.entrypoint; - -import dev.isxander.controlify.api.bind.ControlifyBindApi; -import dev.isxander.controlify.api.contextual.ContextualDomainRegistry; - -public interface PreInitContext { - ControlifyBindApi bindings(); - - ContextualDomainRegistry contextualDomains(); -} diff --git a/src/main/java/dev/isxander/controlify/api/event/ControlifyEvents.java b/src/main/java/dev/isxander/controlify/api/event/ControlifyEvents.java deleted file mode 100644 index 183c6d320..000000000 --- a/src/main/java/dev/isxander/controlify/api/event/ControlifyEvents.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.event; - -import dev.isxander.controlify.InputMode; -import dev.isxander.controlify.api.ingameinput.LookInputModifier; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.platform.EventHandler; - -public final class ControlifyEvents { - public static final EventHandler CONTROLLER_CONNECTED = EventHandler.createPlatformBackedEvent(); - - public static final EventHandler CONTROLLER_DISCONNECTED = EventHandler.createPlatformBackedEvent(); - - /** - * Triggers when the input mode is changed from keyboard to controller or vice versa. - */ - public static final EventHandler INPUT_MODE_CHANGED = EventHandler.createPlatformBackedEvent(); - - /** - * Triggers every tick when the current controller state has been updated. - */ - public static final EventHandler ACTIVE_CONTROLLER_TICKED = EventHandler.createPlatformBackedEvent(); - - /** - * @deprecated Use {@link #ACTIVE_CONTROLLER_TICKED} instead. - */ - @Deprecated - public static final EventHandler CONTROLLER_STATE_UPDATED = ACTIVE_CONTROLLER_TICKED; - - /** - * Triggers every tick when any connected controller's state has been updated before the active controller is ticked. - */ - public static final EventHandler CONTROLLER_STATE_UPDATE = EventHandler.createPlatformBackedEvent(); - - /** - * Triggers in a GUI when the virtual mouse is toggled on or off. - */ - public static final EventHandler VIRTUAL_MOUSE_TOGGLED = EventHandler.createPlatformBackedEvent(); - - /** - * Allows you to modify the look input before it is applied to the player. - * These modifiers are called before the look input is multiplied by the sensitivity. - */ - public static final EventHandler LOOK_INPUT_MODIFIER = EventHandler.createPlatformBackedEvent(); - - public record ControllerConnected(ControllerEntity controller, boolean hotplugged, @Deprecated boolean newController) { - } - - public record ControllerDisconnected(ControllerEntity controller) { - } - - public record InputModeChanged(InputMode mode) { - } - - public record ControllerStateUpdate(ControllerEntity controller) { - } - - public record VirtualMouseToggled(boolean enabled) { - } - -} diff --git a/src/main/java/dev/isxander/controlify/api/font/ActionGlyphs.java b/src/main/java/dev/isxander/controlify/api/font/ActionGlyphs.java new file mode 100644 index 000000000..c82094ea4 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/font/ActionGlyphs.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.font; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.impl.font.ActionGlyphsImpl; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; + +/// Creates text components whose glyphs are resolved by each receiving Controlify client. +/// +/// Components identify an action rather than a controller or a fixed input. When displayed, +/// the client resolves the action's current binding using its selected controller and glyph set. +/// The same component can be used locally or sent by a server to multiple clients. +/// Creating it does not require a controller, a registered action, or client initialization; +/// the action must exist on the receiving client for its binding to be displayed. +/// +/// These factories only use common Minecraft classes and may be called on a dedicated server. +/// They preserve the component's action reference across vanilla component serialization. +/// Use a fallback variant for components that may reach clients without Controlify. +public final class ActionGlyphs { + private ActionGlyphs() { + } + + /// Creates a glyph component for an action, regardless of the client's current input mode. + /// Without a selected controller or Controlify, the keybind text is displayed instead; + /// use {@link #actionWithFallback(Identifier, Component)} for a readable alternative. + public static Component action(Identifier action) { + return ActionGlyphsImpl.action(action); + } + + /// Equivalent to {@link #action(Identifier)}, using only the accessor's action identifier. + /// This does not resolve or retain a controller-specific action instance. + public static Component action(ActionAccessor action) { + return action(action.actionId()); + } + + /// Creates a glyph component that displays {@code fallback} on clients without Controlify. + /// Controlify clients select the glyph branch regardless of their current input mode. + /// This fallback does not handle missing actions, unbound actions, or a missing controller. + /// Both alternatives are included in the serialized component. + public static Component actionWithFallback(Identifier action, Component fallback) { + return ActionGlyphsImpl.actionWithFallback(action, fallback); + } + + /// Equivalent to {@link #actionWithFallback(Identifier, Component)}, using the accessor's identifier. + public static Component actionWithFallback(ActionAccessor action, Component fallback) { + return actionWithFallback(action.actionId(), fallback); + } + + /// Creates a glyph component that displays {@code fallback} unless the receiving client + /// has Controlify and is using controller input mode. This is useful for messages that + /// should show an ordinary keybind or text to keyboard and mouse users. + /// This fallback does not handle missing or unbound actions. + /// Both alternatives are included in the serialized component. + public static Component actionWithActiveFallback(Identifier action, Component fallback) { + return ActionGlyphsImpl.actionWithActiveFallback(action, fallback); + } + + /// Equivalent to {@link #actionWithActiveFallback(Identifier, Component)}, using the accessor's identifier. + public static Component actionWithActiveFallback(ActionAccessor action, Component fallback) { + return actionWithActiveFallback(action.actionId(), fallback); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/font/package-info.java b/src/main/java/dev/isxander/controlify/api/font/package-info.java new file mode 100644 index 000000000..6894a251f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/font/package-info.java @@ -0,0 +1,5 @@ +/// Components for displaying controller action glyphs. +@NullMarked +package dev.isxander.controlify.api.font; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/gui/ButtonGuideApi.java b/src/main/java/dev/isxander/controlify/api/gui/ButtonGuideApi.java new file mode 100644 index 000000000..e96b34a00 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/gui/ButtonGuideApi.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.gui; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import net.minecraft.client.gui.screens.Screen; + +import java.util.function.Supplier; + +/** + * Adds a guide to a button. This does not invoke the button press on binding trigger, only renders the guide. + * This should be called every time a button is initialised, like in {@link Screen#init()} + */ +public interface ButtonGuideApi { + + /** + * Makes the button render the image of the binding specified. + * This does not invoke the button press on binding trigger, only renders the guide. + * Custom behaviour should be handled inside a {@link ScreenBehaviour} or {@link ElementBehaviour}. + * + * @param button button to render the guide for + * @param action the action binding to render + * @param renderPredicate whether the guide should be rendered + */ + void addGuideToButton( + T button, + ActionAccessor action, + ButtonGuidePredicate renderPredicate + ); + + void addGuideToButton( + T button, + Supplier action, + ButtonGuidePredicate renderPredicate + ); +} diff --git a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuidePredicate.java b/src/main/java/dev/isxander/controlify/api/gui/ButtonGuidePredicate.java similarity index 57% rename from src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuidePredicate.java rename to src/main/java/dev/isxander/controlify/api/gui/ButtonGuidePredicate.java index 7fcd6bf2e..4b18006ed 100644 --- a/src/main/java/dev/isxander/controlify/api/buttonguide/ButtonGuidePredicate.java +++ b/src/main/java/dev/isxander/controlify/api/gui/ButtonGuidePredicate.java @@ -4,9 +4,8 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.api.buttonguide; +package dev.isxander.controlify.api.gui; -import net.minecraft.client.gui.components.AbstractButton; import net.minecraft.client.gui.components.AbstractWidget; @FunctionalInterface @@ -21,12 +20,4 @@ static ButtonGuidePredicate focusOnly() { static ButtonGuidePredicate always() { return btn -> true; } - - /** Always display the button guide. */ - @Deprecated - ButtonGuidePredicate ALWAYS = btn -> true; - - /** Only display the button guide when the button is focused. */ - @Deprecated - ButtonGuidePredicate FOCUS_ONLY = AbstractWidget::isFocused; } diff --git a/src/main/java/dev/isxander/controlify/api/gui/keyboard/InputTarget.java b/src/main/java/dev/isxander/controlify/api/gui/keyboard/InputTarget.java new file mode 100644 index 000000000..f0205f964 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/gui/keyboard/InputTarget.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.gui.keyboard; + +/** + * Represents a target/consumer for keyboard input. + * A target accepts input from the on-screen keyboard. + * It can have various capabilities, all of which are optional to implement. + */ +public interface InputTarget { + InputTarget EMPTY = new InputTarget() { + }; + + default boolean supportsCharInput() { + return false; + } + + default boolean acceptChar(char ch, int modifiers) { + return false; + } + + default boolean supportsKeyCodeInput() { + return false; + } + + default boolean acceptKeyCode(int keycode, int scancode, int modifiers) { + return false; + } + + default boolean supportsCopying() { + return false; + } + + default boolean copy() { + return false; + } + + default boolean supportsCursorMovement() { + return false; + } + + default boolean moveCursor(int amount) { + return false; + } + +} diff --git a/src/main/java/dev/isxander/controlify/api/gui/keyboard/KeyboardLayouts.java b/src/main/java/dev/isxander/controlify/api/gui/keyboard/KeyboardLayouts.java new file mode 100644 index 000000000..fc144e8bc --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/gui/keyboard/KeyboardLayouts.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.gui.keyboard; + +import net.minecraft.resources.Identifier; + +/// Resource-backed keyboard layout identifiers. Custom layouts use their own identifier. +public final class KeyboardLayouts { + public static final Identifier FULL = Identifier.fromNamespaceAndPath("controlify", "full"); + public static final Identifier SIMPLE = Identifier.fromNamespaceAndPath("controlify", "simple"); + public static final Identifier SERVER_IP = Identifier.fromNamespaceAndPath("controlify", "server_ip"); + + private KeyboardLayouts() { + } +} diff --git a/src/main/java/dev/isxander/controlify/api/gui/keyboard/KeyboardPositioner.java b/src/main/java/dev/isxander/controlify/api/gui/keyboard/KeyboardPositioner.java new file mode 100644 index 000000000..077d08e11 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/gui/keyboard/KeyboardPositioner.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.gui.keyboard; + +import net.minecraft.client.gui.navigation.ScreenAxis; +import net.minecraft.client.gui.navigation.ScreenRectangle; + +import java.util.function.Supplier; + +@FunctionalInterface +public interface KeyboardPositioner { + ScreenRectangle positionKeyboard(int screenWidth, int screenHeight); + + static KeyboardPositioner aboveOrBelowWidgetPositioner(int desiredKeyboardWidth, int desiredKeyboardHeight, int padding, Supplier widgetRectSupplier) { + return (screenWidth, screenHeight) -> { + ScreenRectangle widgetRect = widgetRectSupplier.get(); + + int keyboardWidth = Math.min(desiredKeyboardWidth, screenWidth); + int keyboardLeft = Math.clamp( + widgetRect.getCenterInAxis(ScreenAxis.HORIZONTAL) - keyboardWidth / 2, + 0, screenWidth - keyboardWidth + ); + + int spaceBelow = screenHeight - widgetRect.bottom() - padding; + int spaceAbove = widgetRect.top() - padding; + + // Determine the best vertical position for the keyboard. + // 1. Prefer placing the keyboard below the widget, if it fits. + // 2. If there's not enough space below, check space above. + // 3. If neither fits fully, pick the side with more space and shrink the height. + int keyboardHeight; + boolean above; + if (spaceBelow >= desiredKeyboardHeight) { + keyboardHeight = desiredKeyboardHeight; + above = false; + } else if (spaceAbove >= desiredKeyboardHeight) { + keyboardHeight = desiredKeyboardHeight; + above = true; + } else if (spaceBelow >= spaceAbove) { + keyboardHeight = spaceBelow; + above = false; + } else { + keyboardHeight = spaceAbove; + above = true; + } + int keyboardTop = above + ? widgetRect.top() - keyboardHeight - padding + : widgetRect.bottom() + padding; + + return new ScreenRectangle( + keyboardLeft, + keyboardTop, + keyboardWidth, + keyboardHeight + ); + }; + } +} diff --git a/src/main/java/dev/isxander/controlify/api/gui/keyboard/package-info.java b/src/main/java/dev/isxander/controlify/api/gui/keyboard/package-info.java new file mode 100644 index 000000000..ba2725ad8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/gui/keyboard/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.gui.keyboard; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/gui/package-info.java b/src/main/java/dev/isxander/controlify/api/gui/package-info.java new file mode 100644 index 000000000..a9610dd28 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/gui/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.gui; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/ingame/InGameEvents.java b/src/main/java/dev/isxander/controlify/api/ingame/InGameEvents.java new file mode 100644 index 000000000..dbe31e19e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/ingame/InGameEvents.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.ingame; + +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.ingame.InGameInputHandler; + +public final class InGameEvents { + /// Modifies the final look impulse at the existing pre-application hook. + public static final EventSource LOOK_INPUT_MODIFIER = InGameInputHandler.Events.LOOK_INPUT_MODIFIER; + + private InGameEvents() {} +} diff --git a/src/main/java/dev/isxander/controlify/api/ingame/LookInputModifier.java b/src/main/java/dev/isxander/controlify/api/ingame/LookInputModifier.java new file mode 100644 index 000000000..7c1327c9e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/ingame/LookInputModifier.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.ingame; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.utils.event.EventSink; +import org.joml.Vector2f; + +import java.util.function.BiFunction; +import java.util.function.Predicate; + +/// Modify lookInput in place before application to the player. Callbacks compose in +/// subscription order. The vector belongs to this dispatch and must not be retained. +public record LookInputModifier(Vector2f lookInput, Controller controller) { + public static EventSink functional( + BiFunction x, BiFunction y) { + return event -> { + event.lookInput.x = x.apply(event.lookInput.x, event.controller); + event.lookInput.y = y.apply(event.lookInput.y, event.controller); + }; + } + + public static EventSink zeroIf(Predicate condition) { + return event -> { + if (condition.test(event.controller)) { + event.lookInput.set(0, 0); + } + }; + } +} diff --git a/src/main/java/dev/isxander/controlify/api/ingame/package-info.java b/src/main/java/dev/isxander/controlify/api/ingame/package-info.java new file mode 100644 index 000000000..c1866e377 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/ingame/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.ingame; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/ingameinput/LookInputModifier.java b/src/main/java/dev/isxander/controlify/api/ingameinput/LookInputModifier.java deleted file mode 100644 index c2f7f51d1..000000000 --- a/src/main/java/dev/isxander/controlify/api/ingameinput/LookInputModifier.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.ingameinput; - -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.platform.EventHandler; -import org.joml.Vector2f; - -import java.util.function.BiFunction; -import java.util.function.Predicate; - -/** - * Callbacks should modify the {@link #lookInput()} vector to modify the look input. - * Be aware that multiple callbacks could be called, so make sure to be considerate of other mods. - */ -public record LookInputModifier(Vector2f lookInput, ControllerEntity controller) { - /** - * Creates a callback to modify the x and y individually. - * - * @param x the function for modifying the x-axis - * @param y the function for modifying the y-axis - * @return the new callback - */ - public static EventHandler.Callback functional(BiFunction x, BiFunction y) { - return event -> { - event.lookInput.x = x.apply(event.lookInput.x, event.controller); - event.lookInput.y = y.apply(event.lookInput.y, event.controller); - }; - } - - /** - * Creates a new callback that zeroes out the look input if the predicate is true. - * - * @param condition the condition that, if true, sets both axes to zero - * @return the new LookInputModifier object - */ - static EventHandler.Callback zeroIf(Predicate condition) { - return event -> { - if (condition.test(event.controller)) { - event.lookInput.set(0, 0); - } - }; - } -} diff --git a/src/main/java/dev/isxander/controlify/api/input/LogicalInput.java b/src/main/java/dev/isxander/controlify/api/input/LogicalInput.java new file mode 100644 index 000000000..afdd6583f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/input/LogicalInput.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.input; + +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.ApiStatus; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/// A reference to a logical button or axis. +/// +/// A logical input's domain is the type of input, meaning that +/// a logical input that refers to the same physical inputs can be +/// safely duplicated between continuous and digital signals. +/// +/// Most of the time this will refer to a single physical button or axis, +/// like the `A` button or `Left Stick` left, but it may also refer to +/// a chord, like `A` + `B`. +public sealed interface LogicalInput { + /// Every physical input this logical input is made of. + Set physicalInputs(); + + /// A logical input with a direct relationship with a physical input. + record Single(Identifier physicalInput) implements LogicalInput { + + @Override + public Set physicalInputs() { + return Set.of(this.physicalInput); + } + } + + /// A logical input that triggers only after all modifiers are already pressed. + /// When several chords match a press, the one with the most modifiers wins. + record ModifiedChord(Set modifiers, Identifier trigger) implements LogicalInput { + /// Keeps the number of chords recognised for each press small. + public static final int MAX_MODIFIERS = 2; + + public ModifiedChord { + modifiers = Set.copyOf(modifiers); + validate(modifiers, trigger).ifPresent(error -> { + throw new IllegalArgumentException(error); + }); + } + + @Override + public Set physicalInputs() { + return Stream.concat(this.modifiers.stream(), Stream.of(this.trigger)) + .collect(Collectors.toUnmodifiableSet()); + } + + @ApiStatus.Internal + public static Optional validate(Set modifiers, Identifier trigger) { + if (modifiers.isEmpty() || modifiers.size() > MAX_MODIFIERS || modifiers.contains(trigger)) { + return Optional.of("A chord needs 1 to " + MAX_MODIFIERS + + " modifiers, none of which is the trigger: " + modifiers + " + " + trigger); + } + return Optional.empty(); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/api/input/package-info.java b/src/main/java/dev/isxander/controlify/api/input/package-info.java new file mode 100644 index 000000000..efd120283 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/input/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.input; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/package-info.java b/src/main/java/dev/isxander/controlify/api/package-info.java new file mode 100644 index 000000000..1e9f2de6c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviour.java b/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviour.java new file mode 100644 index 000000000..5afe2279a --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviour.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import dev.isxander.controlify.api.controller.Controller; + +import java.util.Optional; +import java.util.function.Consumer; + +/// Controller support for one GUI element within one screen context. +/// Registered [factories][ElementBehaviourFactory] create stateful instances lazily; +/// [ScreenContext#elementBehaviour] resolves the composed chain by element identity. +/// +/// Methods with `next` use synchronous, at-most-once continuations. Omitting +/// `next` stops the remaining chain for that invocation, including outer focused +/// elements and remaining screen controls when called by built-in input routing. It does +/// not consume action pulses or cancel another stage. Never retain a continuation. +/// +/// Navigation runs before interaction. Each stage resolves its own focus path from +/// deepest to outermost, so interaction can target an element newly focused by navigation. +/// Focus notifications are broadcast independently of input cancellation; keyboard policies +/// are resolved by the first non-empty result rather than by a continuation. +public interface ElementBehaviour { + /// Handles directional input before the screen's interaction stage. + /// The built-in dispatcher invokes the deepest focused element first, then its focused + /// ancestors, then the remaining screen navigation. Call `next` to allow that + /// routing, or omit it after handling navigation, such as adjusting an editing slider. + /// + /// Stopping navigation does not stop interaction. Focus is synchronized after the + /// navigation stage, and interaction resolves the resulting path afresh. Pulses for + /// unconsumed actions remain subject to the action system's own lifetime rules. + /// + /// @param screen the screen context owning this element behaviour + /// @param controller the controller whose actions are being processed + /// @param next continues element and screen navigation synchronously, at most once + default void handleNavigation(ScreenContext screen, Controller controller, Runnable next) { + next.run(); + } + + /// Handles activation, Back, or other actions after navigation and focus synchronization. + /// With built-in routing, the deepest currently focused element runs before its ancestors + /// and the remaining screen interaction behaviours. + /// + /// Omit `next` to handle this interaction exclusively. This does not consume + /// unrelated pulses or prevent future input calls; drain conflicting actions explicitly + /// when they must also be suppressed. + /// + /// @param screen the screen context owning this element behaviour + /// @param controller the controller whose actions are being processed + /// @param next continues element and screen interaction synchronously, at most once + default void handleInteraction(ScreenContext screen, Controller controller, Runnable next) { + next.run(); + } + + /// Notifies this behaviour when its element enters or leaves the screen's focus path. + /// Focused ancestors receive notifications as well as the deepest element. Changes made + /// by other elements or by the screen are included when the context synchronizes focus. + /// The order of notifications between different elements is unspecified. + /// + /// This is a broadcast lifecycle notification: every behaviour in the element's + /// composed chain receives it, regardless of previous input cancellation. + /// [ElementBehaviours#conditional] and [ElementBehaviours#enabledWidget] + /// forward it even while their conditions are false. Use it to reset editing or transient + /// state; there is no continuation to call or suppress. Screen activation changes are + /// separate and do not themselves imply that the focus path has changed. + /// + /// @param screen the screen context owning this element behaviour + /// @param focused whether this element is now in that context's focus path + default void onFocusChanged(ScreenContext screen, boolean focused) { + } + + /// Supplies this element's policy for on-screen keyboard activation. + /// The first non-empty result in the composed chain wins. An empty result lets later + /// behaviours supply a policy; [ElementKeyboardBehaviour.DoNothing] explicitly + /// handles activation without opening a popup, for example when the screen embeds a keyboard. + /// + /// This query has no continuation and is independent of input cancellation. Conditional + /// wrappers return empty while disabled. Returning a policy does not itself open a keyboard; + /// [ScreenContext#tryOpenKeyboard] performs that operation. + /// + /// @param screen the screen context owning this element behaviour + /// @return a keyboard policy, or empty to inherit a later behaviour's policy + default Optional keyboardBehaviour(ScreenContext screen) { + return Optional.empty(); + } + + /// Contributes this element's virtual mouse targets in scaled GUI coordinates. + /// The built-in remaining chain supplies widget centres or child traversal for containers. + /// Omit `next` to replace those contributions, including the descendants of a + /// container. This collection follows the element hierarchy, independently of focus. + /// + /// Read current layout geometry on each call and use `output` only during + /// this invocation. + /// + /// @param screen the screen context owning this element behaviour + /// @param output receives snap points contributed by this element + /// @param next continues element snap point collection synchronously, at most once + default void collectSnapPoints(ScreenContext screen, Consumer output, Runnable next) { + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviourFactory.java b/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviourFactory.java new file mode 100644 index 000000000..968b9b163 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviourFactory.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import net.minecraft.client.gui.components.events.GuiEventListener; + +/// Constructs an element sidecar within a particular screen context. +/// Factories are explicitly registered during the per-screen element registration event +/// and may capture that event's [ScreenContext]. Separate registrations compose; +/// return a fresh stateful behaviour for each element rather than sharing mutable state +/// between elements or screens. +/// +/// @param the element type accepted by this factory +@FunctionalInterface +public interface ElementBehaviourFactory { + /// Creates a behaviour when this element is first resolved in the owning context. + /// The result is cached by element identity while retained by that context. Do not + /// recursively resolve the same element through [ScreenContext#elementBehaviour] + /// from this method; compose the needed behaviours directly instead. + /// + /// @param element the GUI element receiving controller support + /// @return the non-null sidecar to retain for this element + ElementBehaviour create(T element); +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviourRegistry.java b/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviourRegistry.java new file mode 100644 index 000000000..5ac8ba2b8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviourRegistry.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import net.minecraft.client.gui.components.events.GuiEventListener; + +/// Registers additive element behaviour factories during +/// [ScreenopEvents#REGISTER_ELEMENT_BEHAVIOURS]. +/// This registry belongs to one screen context, so the same element type can receive different support on different screens. +/// Matching registrations compose rather than replace one another. Registration is +/// only allowed while the event is being dispatched. +public interface ElementBehaviourRegistry { + /// Adds a factory for the supplied type and its subtypes at an explicit priority. + /// Higher priorities run first. Within a priority, more specific assignable types run + /// before their supertypes; otherwise newer registrations run first. Positive and + /// negative priorities are supported, and the ordinary default is zero. + /// + /// Each matching registration contributes a behaviour to the chain. Use + /// [ElementBehaviours#chain] when a group requires a fixed internal order without + /// assigning separate priorities to its members. + /// + /// @param the registered element type + /// @param elementClass the element class or interface to match, including subtypes + /// @param priority the ordering priority; higher values run earlier + /// @param factory creates a behaviour lazily for each matching element in this screen context + /// @throws IllegalStateException if registration has closed + void register(Class elementClass, int priority, ElementBehaviourFactory factory); + + /// Adds a factory for the supplied type and its subtypes at priority zero. + /// Other matching factories remain in the chain. Within this priority, more specific + /// assignable types run first, then newer registrations. Equivalent to calling the + /// explicit-priority overload with `0`. + /// + /// @param the registered element type + /// @param elementClass the element class or interface to match, including subtypes + /// @param factory creates a behaviour lazily for each matching element in this screen context + /// @throws IllegalStateException if registration has closed + default void register(Class elementClass, ElementBehaviourFactory factory) { + register(elementClass, 0, factory); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviours.java b/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviours.java new file mode 100644 index 000000000..050f2a998 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ElementBehaviours.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import dev.isxander.controlify.impl.screenop.CompositeElementBehaviour; +import dev.isxander.controlify.impl.screenop.ConditionalElementBehaviour; +import dev.isxander.controlify.impl.screenop.behaviours.element.ButtonBehaviour; +import dev.isxander.controlify.impl.screenop.behaviours.element.CycleBehaviour; +import dev.isxander.controlify.impl.screenop.behaviours.element.LayoutElementSnapBehaviour; +import dev.isxander.controlify.impl.screenop.behaviours.element.SliderBehaviour; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.layouts.LayoutElement; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.ObjIntConsumer; +import java.util.function.Predicate; + +/// Composition and reusable controller handling for GUI elements. +/// Register the returned factories with [ElementBehaviourRegistry] to create independent +/// state for each element in each screen. The button, slider, and cycle helpers use +/// callbacks so custom controls can share the input semantics of vanilla controls. +/// +/// The input helpers participate in focus-based control and pass through while the +/// virtual mouse is enabled. They do not check arbitrary element availability; use +/// [#conditional] or [#enabledWidget] when an enabled or visible check is needed. +public final class ElementBehaviours { + private ElementBehaviours() { + } + + /// Composes a fixed ordered group without assigning priorities to individual members. + /// Each member's `next` invokes the following member; the last `next` resumes the + /// enclosing chain. Omitting `next` stops that invocation, and an empty group continues + /// immediately. The list is copied while its behaviour instances are retained. + /// + /// Focus notifications are broadcast to every member, independently of cancellation. + /// Keyboard policy queries use the first non-empty member result. Input propagation + /// stops if the owning screen ceases to be current. + /// + /// @param behaviours the members in invocation order + /// @return the composed element behaviour + public static ElementBehaviour chain(List behaviours) { + return new CompositeElementBehaviour(behaviours); + } + + /// Composes behaviours in argument order, with the last `next` resuming the enclosing + /// chain. Omitting `next` stops that invocation; no arguments produces a pass-through + /// behaviour. Focus notifications reach every member and the first non-empty keyboard + /// policy wins. Input propagation stops when the owning screen is no longer current. + /// + /// This is the varargs equivalent of [#chain(List)] and retains the supplied instances. + /// + /// @param behaviours the members in invocation order + /// @return the composed element behaviour + public static ElementBehaviour chain(ElementBehaviour... behaviours) { + return chain(List.of(behaviours)); + } + + /// Wraps a retained behaviour with a live participation condition. + /// The condition is checked separately for navigation, interaction, snap point collection, + /// and keyboard policy queries. A false condition continues the enclosing chain for + /// continuation-based callbacks and returns an empty keyboard policy. + /// + /// [ElementBehaviour#onFocusChanged] always reaches the member, even while disabled, + /// so transient state such as slider editing can be reset. This skips the wrapped member; + /// it does not cancel other registered behaviours or limit checks to interaction alone. + /// + /// @param condition whether the member should participate in this callback + /// @param behaviour the stateful behaviour to retain + /// @return the conditional behaviour + public static ElementBehaviour conditional(BooleanSupplier condition, ElementBehaviour behaviour) { + return new ConditionalElementBehaviour(condition, behaviour); + } + + /// Creates behaviours that participate while their widgets are both active and visible. + /// The factory runs once per element resolution; availability is re-evaluated on every + /// callback through [#conditional]. Unavailable widgets pass through navigation, + /// interaction, and snap point collection, and supply no keyboard policy. + /// + /// Focus notifications always reach the wrapped behaviour, including when the widget + /// has become inactive or invisible. This wrapper does not suppress other behaviours + /// registered for the widget. + /// + /// @param the widget type + /// @param factory constructs the behaviour to guard for each widget + /// @return a factory applying the live active-and-visible condition + public static ElementBehaviourFactory enabledWidget(ElementBehaviourFactory factory) { + return widget -> conditional(() -> widget.active && widget.visible, factory.create(widget)); + } + + /// Confines continuation cancellation to an inner behaviour or composed group. + /// The enclosing chain resumes after the inner callback returns normally, even if the + /// inner behaviour omitted `next`. Work after an inner continuation finishes before + /// the enclosing chain resumes. + /// + /// Exceptions propagate, and changing screens still stops input propagation. Focus + /// notifications and first-present keyboard policy selection retain their ordinary + /// semantics. Consumed action pulses are not restored by the branch. + /// + /// @param behaviour the member or group whose cancellation is local + /// @return the branch behaviour + public static ElementBehaviour branch(ElementBehaviour behaviour) { + return CompositeElementBehaviour.branch(behaviour); + } + + /// Creates button-like activation handling with the standard GUI click sound. + /// During focus-based interaction, a pending GUI press drains all accumulated press + /// pulses and invokes `press` once. Activation plays the click sound and navigation + /// haptic, then stops interaction propagation. With no press, or while the virtual mouse + /// is enabled, the behaviour continues. + /// + /// This helper performs no availability check; wrap it with [#enabledWidget] or + /// [#conditional] as appropriate. + /// + /// @param the element type + /// @param press activates the element + /// @return a factory creating button handling for each element + public static ElementBehaviourFactory button(Consumer press) { + return element -> new ButtonBehaviour(() -> press.accept(element)); + } + + /// Creates button-like activation handling using an element-specific click sound. + /// During focus-based interaction, a pending GUI press drains all accumulated press + /// pulses, invokes `sound`, plays the navigation haptic, and invokes `press` once. + /// Handled activation stops interaction propagation. Without a press, or while the + /// virtual mouse is enabled, the behaviour continues. + /// + /// The supplied sound callback replaces the standard click sound. Availability is not + /// checked by this helper; wrap the factory when needed. + /// + /// @param the element type + /// @param press activates the element + /// @param sound plays this element's activation sound + /// @return a factory creating button handling for each element + public static ElementBehaviourFactory button(Consumer press, Consumer sound) { + return element -> new ButtonBehaviour(() -> press.accept(element), () -> sound.accept(element)); + } + + /// Creates slider handling with editing state owned by the behaviour. + /// GUI press toggles editing during interaction. While editing, Back exits editing and + /// drains a simultaneous press so it cannot immediately reopen editing or close the + /// screen. Focus changes reset editing. + /// + /// During navigation, primary horizontal input adjusts while editing; secondary + /// horizontal input adjusts directly regardless of editing. Each pulse invokes `adjust` + /// with -1 or +1 and plays navigation haptics. Handled input stops its stage's propagation; + /// unhandled input continues. The helper passes through while the virtual mouse is enabled. + /// Since navigation precedes interaction, a press changes editing for subsequent + /// navigation calls, not for navigation already processed in that fixed update. + /// + /// @param the element type + /// @param adjust changes the value by one logical step in the supplied direction + /// @return a factory creating independently stateful slider handling for each element + public static ElementBehaviourFactory slider(ObjIntConsumer adjust) { + return element -> new SliderBehaviour(direction -> adjust.accept(element, direction)); + } + + /// Adjusts a value with secondary horizontal navigation while preserving normal activation. + /// Use for controls whose keyboard activation or editing state is managed by another UI. + public static ElementBehaviourFactory secondarySlider(ObjIntConsumer adjust) { + return element -> SliderBehaviour.secondaryNavigation(direction -> adjust.accept(element, direction)); + } + + /// Creates discrete option handling for secondary horizontal navigation and activation. + /// Secondary left/right pulses call `cycle` with -1/+1 during navigation; GUI press + /// pulses call it with +1 during interaction. Every pulse is applied separately, including + /// repeated presses, with a navigation haptic for each step. + /// + /// Handled input stops propagation for that stage; otherwise the behaviour continues. + /// The helper passes through while the virtual mouse is enabled and does not check + /// element availability itself. + /// + /// @param the element type + /// @param cycle changes the option by one logical step in the supplied direction + /// @return a factory creating cycle handling for each element + public static ElementBehaviourFactory cycle(ObjIntConsumer cycle) { + return element -> new CycleBehaviour(direction -> cycle.accept(element, direction)); + } + + /// Creates slider handling that shares the element's existing editing state. + /// GUI press toggles the state read by `isEditing` through `setEditing`; Back exits + /// editing and drains a simultaneous press. Every focus change writes `false` so editing + /// is reset even if the element became unavailable. + /// + /// Primary horizontal navigation adjusts only while editing; secondary horizontal + /// navigation adjusts directly in either state. Every pulse invokes `adjust` with -1 + /// or +1 and plays navigation haptics. The adjustment callback must work even when + /// `isEditing` is false. Handled input stops that stage; unhandled input continues, + /// and all input passes through while the virtual mouse is enabled. + /// + /// Navigation runs before interaction, so editing changes from a press affect subsequent + /// navigation calls. Use this overload when rendering or keyboard handling also depends + /// on the element's editing state. + /// + /// @param the element type + /// @param adjust changes the value by one logical step without requiring editing mode + /// @param isEditing reads the element's current editing state + /// @param setEditing updates or resets the element's editing state + /// @return a factory creating slider handling backed by the element's state + public static ElementBehaviourFactory slider( + ObjIntConsumer adjust, + Predicate isEditing, + BiConsumer setEditing + ) { + return element -> new SliderBehaviour( + direction -> adjust.accept(element, direction), + () -> isEditing.test(element), + editing -> setEditing.accept(element, editing) + ); + } + + /// Contributes a centre snap point using the element's current layout geometry, then + /// continues the snap point chain. Coordinates and capture range use scaled GUI pixels. + /// The element need only implement `LayoutElement`; it need not be a vanilla widget. + /// + /// This helper performs no active or visible check. Apply eligibility rules through + /// another behaviour or a conditional wrapper where appropriate. + /// + /// @param element the layout element whose live bounds determine the snap point + /// @return the layout-based snap point behaviour + public static ElementBehaviour snapPoint(LayoutElement element) { + return new LayoutElementSnapBehaviour(element); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ElementKeyboardBehaviour.java b/src/main/java/dev/isxander/controlify/api/screenop/ElementKeyboardBehaviour.java new file mode 100644 index 000000000..d99cff893 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ElementKeyboardBehaviour.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import dev.isxander.controlify.api.gui.keyboard.InputTarget; +import dev.isxander.controlify.api.gui.keyboard.KeyboardPositioner; +import net.minecraft.resources.Identifier; + +/// Policy describing whether and how an element handles on-screen keyboard activation. +/// [ElementBehaviour#keyboardBehaviour] returns an optional policy: an empty result +/// inherits the next behaviour's policy, while either variant here explicitly handles +/// activation. The first present policy in an element's chain wins. +/// +/// Policies are interpreted by [ScreenContext#tryOpenKeyboard]; merely returning one +/// does not open a keyboard or consume action pulses. +public sealed interface ElementKeyboardBehaviour { + + /// Handles keyboard activation without opening a popup or requesting ordinary activation. + /// Use this when the screen already supplies keyboard support, such as an embedded + /// keyboard widget. This explicit policy wins over later policies; it differs from an + /// empty optional, which delegates to them. + record DoNothing() implements ElementKeyboardBehaviour { + } + + /// Handles activation by requesting a popup keyboard for the element. + /// The user's on-screen keyboard setting determines whether the popup actually opens; + /// activation is still considered handled when that setting disables it. + /// + /// @param layout the resource identifier of the keyboard layout to load + /// @param inputTarget receives text and editing input from the popup keyboard + /// @param positioner positions the popup relative to the parent screen + record Handled( + Identifier layout, + InputTarget inputTarget, + KeyboardPositioner positioner + ) implements ElementKeyboardBehaviour { + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviour.java b/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviour.java new file mode 100644 index 000000000..39054229e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviour.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import dev.isxander.controlify.api.controller.Controller; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.function.Consumer; + +/// Controller support attached to a screen without modifying the screen's class hierarchy. +/// Instances are created by registered [factories][ScreenBehaviourFactory] and retain +/// state across input calls, widget rebuilds, and reactivation of the same screen. +/// +/// Methods taking `next` form independent middleware chains. Call it synchronously +/// at most once to continue that invocation, or omit it to stop the remaining behaviours +/// for that method. It must not be stored for later use. Work after `next.run()` +/// runs after the remaining chain returns. [#onScreenActiveChanged(boolean)] is +/// broadcast instead and cannot be cancelled through a continuation. +/// +/// Each controller fixed update runs navigation, synchronizes focus, then runs +/// interaction if the same screen is still current. Cancelling either chain only affects +/// that stage; it does not cancel the other stage or consume any action pulses. Consume +/// or drain pulses explicitly when suppressing another action. Changing screens stops +/// further input dispatch for the previous screen. +public interface ScreenBehaviour { + /// Handles navigation during a controller fixed update, before interaction. + /// Use this stage to move focus or adjust values through directional input. With the + /// built-in routing, focused elements receive navigation from deepest to outermost + /// before the remaining screen controls. + /// + /// Omitting `next` stops further navigation only. Interaction still runs on the + /// same screen using the resulting focus path, even if this method handled navigation. + /// + /// @param controller the controller whose actions are being processed + /// @param next continues the navigation chain synchronously, at most once + default void handleNavigation(Controller controller, Runnable next) { + next.run(); + } + + /// Handles activation, Back, and other interactions after navigation has completed. + /// Focus is synchronized between the stages, so built-in element routing targets the + /// focus path produced by navigation rather than the path at the start of the update. + /// + /// Omitting `next` prevents remaining interaction behaviours from running for + /// this invocation. It does not undo navigation, affect later updates, or drain pulses + /// belonging to actions this behaviour did not consume. + /// + /// @param controller the controller whose actions are being processed + /// @param next continues the interaction chain synchronously, at most once + default void handleInteraction(Controller controller, Runnable next) { + next.run(); + } + + /// Responds to initialization or rebuilding of the screen's widgets. + /// The behaviour instance is retained, but widget references may have changed. Resolve + /// new widgets here, for example when attaching button guides. This hook can also be + /// called on the first observation of an already initialized screen. + /// + /// This hook uses continuation propagation; omitting `next` skips later rebuild + /// handlers. It is separate from activation notifications and controller input. + /// + /// @param next continues the widget rebuild chain synchronously, at most once + default void onWidgetRebuild(Runnable next) { + next.run(); + } + + /// Adds controller-related content to the screen's GUI render state. + /// This is a rendering hook, separate from fixed input updates and frame updates; + /// use it for hints, guides, or cursor visuals rather than consuming input pulses. + /// Content may be extracted before or after calling `next` to control ordering. + /// + /// @param graphics the GUI render state extractor + /// @param tickDelta the partial tick supplied by screen rendering + /// @param controller the controller used for the current screen rendering + /// @param next continues render state extraction synchronously, at most once + default void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta, Controller controller, Runnable next) { + next.run(); + } + + /// Contributes targets for virtual mouse snapping in scaled GUI coordinates. + /// Call `output.accept(point)` for each target. The remaining chain normally + /// includes built-in traversal through element behaviours; omitting `next` + /// replaces those remaining contributions, including descendant targets. + /// + /// Collection can happen repeatedly as the cursor moves. Read current geometry each + /// time and do not retain the output consumer beyond this call. + /// + /// @param output receives this screen's snap points + /// @param next continues snap point collection synchronously, at most once + default void collectSnapPoints(Consumer output, Runnable next) { + next.run(); + } + + /// Updates frame-based state before GUI mouse processing. + /// Unlike navigation and interaction, this runs per frame and can run without a + /// controller fixed update. Use it for smooth movement or animation rather than assuming + /// there is a fresh set of action pulses on every call. + /// + /// @param delta elapsed real time in ticks, not seconds or a partial tick fraction + /// @param next continues the frame update chain synchronously, at most once + default void update(float delta, Runnable next) { + next.run(); + } + + /// Notifies this behaviour when its screen becomes or ceases to be the current screen. + /// The same context may activate repeatedly, for example when returning from an overlay; + /// deactivation is not a destruction notification. + /// + /// This notification is broadcast to every member of a composed chain independently + /// of cancellation in any other method. [ScreenBehaviours#conditional] forwards it + /// even when its predicate is false. Release held input or suspend transient state here + /// rather than relying on a later input callback. + /// + /// @param active whether this screen is now current + default void onScreenActiveChanged(boolean active) { + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviourFactory.java b/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviourFactory.java new file mode 100644 index 000000000..294e6ade2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviourFactory.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import net.minecraft.client.gui.screens.Screen; + +/// Constructs a screen sidecar for an explicitly registered screen type. +/// Each matching registration runs once when a screen context is created. Return a fresh +/// stateful behaviour, or compose several with [ScreenBehaviours]; do not recreate +/// stateful behaviours on every input callback. +/// +/// @param the screen type accepted by this factory +@FunctionalInterface +public interface ScreenBehaviourFactory { + /// Creates a behaviour for the supplied screen context. + /// The screen is constructed, but its widgets may not yet be initialized. Per-screen + /// element registration has not finished, so defer calls to + /// [ScreenContext#elementBehaviour] until after factory construction and registration. + /// + /// @param screen the context shared by all behaviours attached to this screen + /// @return the non-null sidecar to retain for this context + ScreenBehaviour create(ScreenContext screen); +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviourRegistry.java b/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviourRegistry.java new file mode 100644 index 000000000..a633a43b7 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviourRegistry.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import net.minecraft.client.gui.screens.Screen; + +/// Registers additive screen behaviour factories during +/// [ScreenopEvents#REGISTER_SCREEN_BEHAVIOURS]. +/// This registry is global to screen types. +/// Matching registrations compose rather than replace one another. Registration is +/// only allowed while the event is being dispatched. +public interface ScreenBehaviourRegistry { + /// Adds a factory for the supplied type and its subtypes at an explicit priority. + /// Higher priorities run first. Within a priority, more specific assignable types run + /// before their supertypes; otherwise newer registrations run first. Positive and + /// negative priorities are supported, and the ordinary default is zero. + /// + /// Each matching registration contributes a behaviour to the chain. Use + /// [ScreenBehaviours#chain] when a group requires a fixed internal order without + /// assigning separate priorities to its members. + /// + /// @param the registered screen type + /// @param screenClass the screen class to match, including subtypes + /// @param priority the ordering priority; higher values run earlier + /// @param factory creates a behaviour once per screen context + /// @throws IllegalStateException if registration has closed + void register(Class screenClass, int priority, ScreenBehaviourFactory factory); + + /// Adds a factory for the supplied type and its subtypes at priority zero. + /// Other matching factories remain in the chain. Within this priority, more specific + /// assignable types run first, then newer registrations. Equivalent to calling the + /// explicit-priority overload with `0`. + /// + /// @param the registered screen type + /// @param screenClass the screen class to match, including subtypes + /// @param factory creates a behaviour once per screen context + /// @throws IllegalStateException if registration has closed + default void register(Class screenClass, ScreenBehaviourFactory factory) { + register(screenClass, 0, factory); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviours.java b/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviours.java new file mode 100644 index 000000000..ce62b366c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ScreenBehaviours.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import dev.isxander.controlify.impl.screenop.CompositeScreenBehaviour; +import dev.isxander.controlify.impl.screenop.ConditionalScreenBehaviour; + +import java.util.List; +import java.util.function.BooleanSupplier; + +/// Composition helpers for stateful [ScreenBehaviour] instances. +/// Create these wrappers once in a [ScreenBehaviourFactory] so member state survives +/// between invocations. Composition controls propagation within each callback; +/// it does not consume action pulses or merge navigation and interaction into one stage. +public final class ScreenBehaviours { + private ScreenBehaviours() { + } + + /// Composes a fixed ordered group of behaviours without assigning member priorities. + /// For callbacks with a continuation, each member's `next` invokes the following member; + /// the last member's `next` resumes the enclosing chain. Omitting `next` stops that + /// invocation. An empty group continues immediately. The supplied list is copied. + /// + /// Activation notifications are broadcast to every member independently of continuations. + /// Navigation, interaction, and frame update propagation also stop if the current screen + /// changes. Member state is retained between calls. + /// + /// @param behaviours the members in invocation order + /// @return the composed behaviour + public static ScreenBehaviour chain(List behaviours) { + return new CompositeScreenBehaviour(behaviours); + } + + /// Composes behaviours in argument order, with the last `next` resuming the enclosing + /// chain. Omitting a continuation stops that invocation; an empty group continues + /// immediately. Activation notifications always reach every member. Screen changes + /// stop further navigation, interaction, and frame update propagation. + /// + /// This is the varargs equivalent of [#chain(List)] and retains the supplied instances. + /// + /// @param behaviours the members in invocation order + /// @return the composed behaviour + public static ScreenBehaviour chain(ScreenBehaviour... behaviours) { + return chain(List.of(behaviours)); + } + + /// Wraps a behaviour with a live participation condition. + /// The predicate is evaluated separately for every continuation-based callback. When + /// false, the member is skipped and the enclosing chain continues. The member is + /// retained rather than reconstructed, so its state survives changes to the predicate. + /// + /// [ScreenBehaviour#onScreenActiveChanged] always reaches the member, even while the + /// predicate is false. A predicate may select different groups for cursor and focus + /// modes without those groups needing to inspect one another. + /// + /// @param condition whether the behaviour should participate in this callback + /// @param behaviour the stateful member to wrap + /// @return the conditional behaviour + public static ScreenBehaviour conditional(BooleanSupplier condition, ScreenBehaviour behaviour) { + return new ConditionalScreenBehaviour(condition, behaviour); + } + + /// Confines cancellation to an inner behaviour, usually an explicit [#chain(List)]. + /// The inner callback runs with a terminal continuation; after it returns normally, + /// the enclosing chain continues even if an inner member omitted `next`. + /// Work after an inner `next.run()` finishes before the enclosing chain resumes. + /// + /// Exceptions propagate and do not resume the outer chain. Changing screens still stops + /// input and frame update propagation. Activation notifications retain their broadcast + /// semantics. This wrapper does not restore or drain consumed action state. + /// + /// @param behaviour the member or composed group whose cancellation is local + /// @return the branch behaviour + public static ScreenBehaviour branch(ScreenBehaviour behaviour) { + return CompositeScreenBehaviour.branch(behaviour); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ScreenContext.java b/src/main/java/dev/isxander/controlify/api/screenop/ScreenContext.java new file mode 100644 index 000000000..3a7a4ba2b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ScreenContext.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import dev.isxander.controlify.api.InputMode; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuideApi; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.screens.Screen; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +/// Shared state and services for behaviours attached to one screen instance. +/// The context survives widget rebuilds and returning to the same screen after an overlay. +/// Screen factories run before per-screen element registration, so element behaviours +/// cannot be resolved until that registration event finishes. Widgets may not yet exist +/// when a screen factory runs; resolve rebuilt widgets in +/// [ScreenBehaviour#onWidgetRebuild] instead. +/// +/// @param the screen type exposed to this behaviour +public interface ScreenContext { + /// Returns the screen owning this context. + /// + /// @return the same screen instance throughout this context's lifetime + T screen(); + + /// Resolves the effective behaviour chain for an element in this screen. + /// Matching factories run lazily and the result is cached by object identity. Repeated + /// lookups return the cached chain while that element remains retained by the context; + /// replaced or detached widgets can be discarded during hierarchy synchronization. + /// The same element used in another screen context has an independent chain. + /// + /// Built-in routing performs ordinary input and snap point lookups. Use this method + /// when a custom behaviour needs to dispatch to a child or query its keyboard policy. + /// + /// @param element the element whose composed behaviour is needed + /// @return the chain of all matching element behaviours, possibly empty + /// @throws IllegalStateException if element registration has not finished, or a factory + /// recursively attempts to resolve its own element + ElementBehaviour elementBehaviour(GuiEventListener element); + + /// Returns the current input mode, evaluated when this method is called. + /// + /// @return the client's current input mode + InputMode inputMode(); + + /// Returns this screen's shared virtual mouse. + /// Use it to configure cursor policy or perform explicit snapping and click operations. + /// + /// @return the cursor instance retained by this context + VirtualMouse virtualMouse(); + + /// Reports the effective enabled state of this screen's virtual mouse. + /// Equivalent to `virtualMouse().isEnabled()`, including activation and input mode + /// requirements rather than checking the configured mode alone. + /// + /// @return whether this screen's cursor is currently active + default boolean isVirtualMouseEnabled() { + return virtualMouse().isEnabled(); + } + + /// Captures the screen's current focus path, deepest element first, followed by its + /// focused ancestors. The screen itself is excluded. This is an immutable snapshot; + /// request a new path after changing focus. + /// + /// @return the current focused elements, or an empty list if nothing is focused + List focusPath(); + + /// Clears the screen's focus and synchronizes element focus notifications immediately. + /// Behaviours whose elements leave the focus path receive + /// [ElementBehaviour#onFocusChanged] with `false`. + void clearFocus(); + + /// Asks the screen to establish its initial focus and synchronizes element notifications. + /// Uses the screen's normal initial-focus logic, including screen-specific overrides. + void setInitialFocus(); + + /// Reports a successful navigation step and supplies standard feedback. + /// Notifies the controller input system, plays navigation haptics when available, and + /// plays the focus-change sound when the extra UI sounds setting is enabled. + /// + /// @param controller the controller responsible for the navigation + void notifyNavigation(Controller controller); + + /// Plays the standard navigation haptic if supported by the controller. + /// This does not report a navigation step or play a sound. + /// + /// @param controller the controller receiving haptic feedback + void playNavigationHaptic(Controller controller); + + /// Plays the standard focus-change sound directly, regardless of the extra UI sounds + /// setting. Use [#notifyNavigation] for navigation feedback that respects that setting. + void playFocusChangeSound(); + + /// Plays the standard GUI button click sound without reporting navigation or playing haptics. + void playClickSound(); + + /// Returns the button guide service for adding controller action hints to widgets. + /// Attach guides to newly created widgets during [ScreenBehaviour#onWidgetRebuild]. + /// + /// @return the button guide API + ButtonGuideApi buttonGuide(); + + /// Resolves an element's keyboard policy and handles activation if one is supplied. + /// A [ElementKeyboardBehaviour.Handled] policy opens a popup when the user's + /// on-screen keyboard setting permits it. A [ElementKeyboardBehaviour.DoNothing] + /// policy suppresses the popup. Both count as handled; a supplied policy also counts as + /// handled when the user has disabled the popup keyboard. + /// + /// A null element, an inactive or invisible widget, or an absent policy returns + /// `false`, allowing ordinary activation to continue. This method does not itself + /// consume activation pulses. + /// + /// @param controller the controller whose keyboard preference is used + /// @param component the element to query, or null when nothing is targeted + /// @return whether activation was handled, including intentional popup suppression + boolean tryOpenKeyboard( + Controller controller, + @Nullable GuiEventListener component + ); +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/ScreenopEvents.java b/src/main/java/dev/isxander/controlify/api/screenop/ScreenopEvents.java new file mode 100644 index 000000000..d618b125f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/ScreenopEvents.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.screenop.ScreenBehaviourRegistryImpl; + +/// Registration events for screen and per-screen element behaviours. +/// Subscribe during client initialization before the corresponding registration dispatch. +/// Use explicit factories; screens and elements do not need to implement factory interfaces. +public final class ScreenopEvents { + + /// Fires once during client initialization to register factories by screen type. + /// All matching registrations compose. The supplied registry closes when dispatch + /// finishes; factories run later as individual screen contexts are created. + public static final EventSource REGISTER_SCREEN_BEHAVIOURS = ScreenBehaviourRegistryImpl.Events.REGISTER_SCREEN_BEHAVIOURS; + + /// Fires once for each new screen context, after its screen behaviour factories run. + /// The event exposes the current context so registrations for the same element type can + /// vary between screens. Widgets may not yet be initialized: register factories here + /// and let them run lazily when elements are resolved. + /// + /// The registry closes after dispatch and is retained across widget rebuilds. Do not + /// resolve element behaviours through the context until this event has finished. + public static final EventSource REGISTER_ELEMENT_BEHAVIOURS = ScreenBehaviourRegistryImpl.Events.REGISTER_ELEMENT_BEHAVIOURS; + + /// Arguments for per-screen element behaviour registration. + /// Factories may capture `screenContext` to share the owning screen's services or select + /// behaviours appropriate to that screen. Neither component represents a global registry. + /// + /// @param screenContext the context whose element registrations are being collected + /// @param registry the additive element registry, writable only during this event + public record RegisterElementBehavioursEvent(ScreenContext screenContext, ElementBehaviourRegistry registry) { + } + + private ScreenopEvents() { + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/SnapPoint.java b/src/main/java/dev/isxander/controlify/api/screenop/SnapPoint.java new file mode 100644 index 000000000..445cbb6ab --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/SnapPoint.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import org.joml.Vector2i; +import org.joml.Vector2ic; + +/// A target for virtual mouse snapping in scaled GUI coordinates. +/// Nearby snapping uses `range` as the capture radius around `position`; directional +/// snapping uses the targets' geometry to select a destination. These units differ from +/// the unscaled window coordinates returned by [VirtualMouse#getCurrentX] and +/// [VirtualMouse#getCurrentY]. +/// +/// The supplied position is retained. Treat its contents as immutable while the point +/// is in use so its geometry and equality remain stable during collection. +/// +/// @param position the target position in scaled GUI pixels +/// @param range the capture radius in scaled GUI pixels +public record SnapPoint(Vector2ic position, int range) { + /// Creates a snap point from scaled GUI coordinates, allocating its position vector. + /// + /// @param x the target's horizontal position in scaled GUI pixels + /// @param y the target's vertical position in scaled GUI pixels + /// @param range the capture radius in scaled GUI pixels + public SnapPoint(int x, int y, int range) { + this(new Vector2i(x, y), range); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/VirtualMouse.java b/src/main/java/dev/isxander/controlify/api/screenop/VirtualMouse.java new file mode 100644 index 000000000..dc2431f4a --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/VirtualMouse.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +import dev.isxander.controlify.api.controller.Controller; +import net.minecraft.client.gui.navigation.ScreenDirection; + +/// Shared cursor state and operations for one [ScreenContext]. +/// The [mode][#mode()] is the screen's policy; [#isEnabled()] reports +/// whether the cursor is actually running given that policy, controller input mode, +/// screen activation, and temporary suspension. Configure a mode in the screen factory +/// and share this instance between behaviours that need cursor operations. +/// +/// Cursor positions returned here use window mouse coordinates, before GUI scaling. +/// [SnapPoint] positions instead use scaled GUI coordinates; snapping performs the +/// conversion. Automatic movement, scrolling, and clicks are provided by screen behaviours. +public interface VirtualMouse { + /// Returns the configured policy, which can differ from the current enabled state. + /// [VirtualMouseMode#DEFAULT] is a distinct policy that follows the saved screen + /// preference and allows user toggling. + /// + /// @return this screen's virtual mouse mode + VirtualMouseMode mode(); + + /// Changes the screen's cursor policy and synchronizes its enabled state. + /// This is an explicit choice, including when selecting [VirtualMouseMode#DEFAULT]. + /// Changing modes releases held emulated mouse buttons and clears queued scrolling when + /// the new mode does not support it. The mode is retained by this screen context. + /// + /// @param mode the policy to apply + void setMode(VirtualMouseMode mode); + + /// Reports whether this cursor is currently active. + /// An enabled policy alone is insufficient: the screen must be active, controller input + /// must be in use with a current controller, and the cursor must not be suspended. + /// + /// @return whether virtual mouse behaviours should currently operate + boolean isEnabled(); + + /// Returns the interpolated horizontal cursor position in window mouse coordinates. + /// This is an unscaled coordinate, not a [SnapPoint] GUI coordinate. + /// + /// @param deltaTime interpolation fraction from the current position to its target, + /// normally between zero and one + /// @return the interpolated x coordinate, truncated to an integer + int getCurrentX(float deltaTime); + + /// Returns the interpolated vertical cursor position in window mouse coordinates. + /// This is an unscaled coordinate, not a [SnapPoint] GUI coordinate. + /// + /// @param deltaTime interpolation fraction from the current position to its target, + /// normally between zero and one + /// @return the interpolated y coordinate, truncated to an integer + int getCurrentY(float deltaTime); + + /// Refreshes this screen's snap points and snaps to the closest eligible point within + /// its capture range. A previously snapped point is excluded until the cursor leaves + /// its range. Does nothing if no target qualifies or this cursor is not enabled on the + /// current screen. + void snapToClosestPoint(); + + /// Refreshes snap points and moves to a suitable target in the supplied direction. + /// Directional selection favours nearby targets aligned with that direction and can + /// wrap from the opposite screen edge when no forward target qualifies. Capture ranges + /// do not act as the same proximity limit used by [#snapToClosestPoint()]. + /// Does nothing if no target qualifies or this cursor is not enabled on the current screen. + /// + /// @param direction the direction in scaled screen space + void snapInDirection(ScreenDirection direction); + + /// Processes virtual mouse click actions explicitly, including when the selected mode + /// disables automatic click emulation. Left and right presses consume accumulated press + /// pulses and remain held until their actions are released; shift-click pulses produce + /// individual press/release pairs. + /// + /// Use this as a fallback for screen-specific interaction. The cursor must still be + /// enabled on the current screen. Consumed pulses cannot also be handled by another + /// click behaviour during the same invocation. + /// + /// @param controller the controller providing click actions + void emulateClicks(Controller controller); + + /// Discards the cursor's currently queued scroll movement on both axes. + /// Call after navigation has queued scrolling to suppress that movement before the next + /// frame applies it. This does not consume scroll action state or prevent a later + /// behaviour from queuing more movement. + void preventScrollingThisTick(); +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/VirtualMouseMode.java b/src/main/java/dev/isxander/controlify/api/screenop/VirtualMouseMode.java new file mode 100644 index 000000000..77b37f509 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/VirtualMouseMode.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.screenop; + +/// Screen-level policy selecting cursor movement, scrolling, and automatic click emulation. +/// The mode is configured through [VirtualMouse#setMode] and is distinct from the live +/// [VirtualMouse#isEnabled] state. All modes still respect screen activation, controller +/// input mode, and temporary cursor suspension. +/// +/// Only [#DEFAULT] follows the saved screen preference and permits user toggling; +/// the other constants select a fixed policy for that screen context. +public enum VirtualMouseMode { + /// Follows the saved preference for this screen type and permits the user to toggle it. + /// When enabled, supplies movement, snapping, scrolling, and automatic click emulation. + /// This is a distinct selectable policy, not a placeholder for another enum value. + DEFAULT, + + /// Requests movement, snapping, scrolling, and automatic click emulation without + /// allowing the user toggle to change this screen's policy. + ENABLED, + + /// Disables the virtual mouse for this screen, leaving focus-based controls available. + DISABLED, + + /// Requests movement and snapping without automatic scrolling or click emulation. + /// Screen-specific behaviours can interpret interactions or call [VirtualMouse#emulateClicks] + /// explicitly when they need a mouse fallback. + CURSOR_ONLY, + + /// Requests movement, snapping, and scrolling without automatic click emulation. + /// Screen-specific behaviours handle interaction, optionally using + /// [VirtualMouse#emulateClicks] as an explicit fallback. + CURSOR_SCROLL; + + /// Reports whether this policy includes automatic mouse-button emulation. + /// This describes the policy alone, so [#DEFAULT] returns true even when the saved + /// preference currently leaves the cursor disabled. + /// + /// @return true for [#DEFAULT] and [#ENABLED] + public boolean emulatesClicks() { + return this == DEFAULT || this == ENABLED; + } + + /// Reports whether this policy includes automatic cursor scrolling. + /// This describes the policy alone and does not indicate that the cursor is enabled. + /// + /// @return true for [#DEFAULT], [#ENABLED], and [#CURSOR_SCROLL] + public boolean scrolls() { + return emulatesClicks() || this == CURSOR_SCROLL; + } +} diff --git a/src/main/java/dev/isxander/controlify/api/screenop/package-info.java b/src/main/java/dev/isxander/controlify/api/screenop/package-info.java new file mode 100644 index 000000000..9482429b0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/screenop/package-info.java @@ -0,0 +1,52 @@ +/// Composable controller support attached to screens and GUI elements as stateful sidecars. +/// Screen implementations can remain independent of controller-specific classes, while +/// registered factories construct the support that Controlify invokes. +/// +/// ## Registration and lifetime +/// +/// Subscribe to [ScreenopEvents#REGISTER_SCREEN_BEHAVIOURS] to register explicit screen +/// factories. Each screen context retains its matching behaviours across widget rebuilds +/// and reactivation. After those factories run, +/// [ScreenopEvents#REGISTER_ELEMENT_BEHAVIOURS] supplies the context and its element +/// registry. Element factories run lazily and results are cached by element identity +/// within that context. Widgets may not exist when registration occurs. +/// +/// Registrations compose rather than replace one another. Higher priorities run first, +/// then more specific assignable types, then newer registrations. Use +/// [ScreenBehaviours#chain] and [ElementBehaviours#chain] for a group with a fixed internal +/// order instead of giving every member its own priority. +/// +/// ## Input stages and continuations +/// +/// Every controller fixed update completes navigation before starting interaction. +/// The built-in focused-element dispatcher visits the deepest focused element, then its +/// ancestors, then continues with remaining screen controls. Focus is synchronized +/// between stages, and interaction resolves the updated focus path. +/// +/// Each `next` is synchronous and may be called at most once. Omitting it stops only the +/// remaining invocation of that method. Cancelling navigation does not cancel interaction, +/// and neither cancellation nor [ScreenBehaviours#branch] consumes or restores action +/// pulses. Consume or drain conflicting actions explicitly. Changing screens stops further +/// input for the previous screen. +/// +/// Branches resume the enclosing chain after the inner callback returns normally, even +/// if it omitted `next`. Conditional wrappers skip their member while a live predicate +/// is false. State survives both forms of composition. +/// +/// ## Notifications, keyboard policies, and cursor state +/// +/// [ElementBehaviour#onFocusChanged] and [ScreenBehaviour#onScreenActiveChanged] are +/// broadcast notifications, independent of input propagation. The composition helpers +/// forward them to every member, including conditionally disabled members. Keyboard +/// policies instead select the first non-empty result; an explicit +/// [ElementKeyboardBehaviour.DoNothing] suppresses popup activation. +/// +/// [ScreenContext] provides the current focus path, feedback, keyboard opening, and the +/// shared [VirtualMouse]. Its [VirtualMouseMode] is a screen policy rather than another +/// middleware instance. Frame updates run before GUI mouse processing and are separate +/// from fixed input updates. [SnapPoint] coordinates are scaled GUI pixels; virtual mouse +/// position getters return unscaled window coordinates. +@NullMarked +package dev.isxander.controlify.api.screenop; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/bindings/output/BindingOutput.java b/src/main/java/dev/isxander/controlify/api/utils/event/EventSink.java similarity index 50% rename from src/main/java/dev/isxander/controlify/bindings/output/BindingOutput.java rename to src/main/java/dev/isxander/controlify/api/utils/event/EventSink.java index 86ef48c1f..8d10a374f 100644 --- a/src/main/java/dev/isxander/controlify/bindings/output/BindingOutput.java +++ b/src/main/java/dev/isxander/controlify/api/utils/event/EventSink.java @@ -4,7 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.bindings.output; +package dev.isxander.controlify.api.utils.event; -public interface BindingOutput { +@FunctionalInterface +public interface EventSink { + void accept(T event); } diff --git a/src/main/java/dev/isxander/controlify/api/utils/event/EventSource.java b/src/main/java/dev/isxander/controlify/api/utils/event/EventSource.java new file mode 100644 index 000000000..7501fe73c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/utils/event/EventSource.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.utils.event; + +import java.util.function.Predicate; + +/// Subscription-only source. Controlify events dispatch synchronously in subscription +/// order on the emitting thread, without replay. Subscribe in ControlifyEntrypoint for +/// one-shot lifecycle events. Listener exceptions propagate and stop that emission. +public interface EventSource { + Subscription subscribe(EventSink listener); + + /// Equivalent to [subscribe] but explicitly discards the subscription return + /// to prevent downstream warnings about not closing. + @SuppressWarnings("resource") + default void subscribeForever(EventSink listener) { + var _ = this.subscribe(listener); + } + + default EventSource via(EventStage stage) { + return sink -> this.subscribe(stage.bind(sink)); + } + + default EventSource filter(Predicate predicate) { + return sink -> this.subscribe(event -> { + if (predicate.test(event)) { + sink.accept(event); + } + }); + } + + @FunctionalInterface + interface Subscription extends AutoCloseable { + /// Unsubscribe this registration; repeated calls have no effect. + @Override + void close(); // unsubscribe; no checked exception + } +} diff --git a/src/main/java/dev/isxander/controlify/api/utils/event/EventStage.java b/src/main/java/dev/isxander/controlify/api/utils/event/EventStage.java new file mode 100644 index 000000000..a0ed347e7 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/utils/event/EventStage.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.utils.event; + +@FunctionalInterface +public interface EventStage { + void onEvent(I event, EventSink downstream); + + default EventStage andThen(EventStage next) { + EventStage first = this; + return new EventStage<>() { + @Override + public void onEvent(I event, EventSink downstream) { + first.onEvent(event, x -> next.onEvent(x, downstream)); + } + + @Override + public EventSink bind(EventSink downstream) { + return first.bind(next.bind(downstream)); + } + }; + } + + /// Connects this stage and any composed stages to a fixed downstream sink once. + default EventSink bind(EventSink downstream) { + return event -> this.onEvent(event, downstream); + } + + default EventSink discardDownstream() { + return this.bind(_ -> {}); + } + + static UnaryEventStage identity() { + return (event, downstream) -> downstream.accept(event); + } +} diff --git a/src/main/java/dev/isxander/controlify/api/utils/event/UnaryEventStage.java b/src/main/java/dev/isxander/controlify/api/utils/event/UnaryEventStage.java new file mode 100644 index 000000000..c054141d9 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/utils/event/UnaryEventStage.java @@ -0,0 +1,11 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.api.utils.event; + +@FunctionalInterface +public interface UnaryEventStage extends EventStage { +} diff --git a/src/main/java/dev/isxander/controlify/api/utils/event/package-info.java b/src/main/java/dev/isxander/controlify/api/utils/event/package-info.java new file mode 100644 index 000000000..e4c0095e1 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/api/utils/event/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.api.utils.event; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/api/vmousesnapping/ISnapBehaviour.java b/src/main/java/dev/isxander/controlify/api/vmousesnapping/ISnapBehaviour.java deleted file mode 100644 index 2a349ed91..000000000 --- a/src/main/java/dev/isxander/controlify/api/vmousesnapping/ISnapBehaviour.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.vmousesnapping; - -import java.util.Set; -import java.util.function.Consumer; - -/** - * An interface to implement by gui components to define snap points for virtual mouse snapping. - * Can also be implemented in a mixin to improve compatibility. - */ -public interface ISnapBehaviour { - default void controlify$collectSnapPoints(Consumer consumer) { - for (SnapPoint point : getSnapPoints()) { - consumer.accept(point); - } - } - - @Deprecated - default Set getSnapPoints() { - return Set.of(); - } -} diff --git a/src/main/java/dev/isxander/controlify/api/vmousesnapping/SnapPoint.java b/src/main/java/dev/isxander/controlify/api/vmousesnapping/SnapPoint.java deleted file mode 100644 index ffa6bd946..000000000 --- a/src/main/java/dev/isxander/controlify/api/vmousesnapping/SnapPoint.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.api.vmousesnapping; - -import org.joml.Vector2i; -import org.joml.Vector2ic; - -/** - * Defines a point on the screen that the virtual mouse can snap to. - * - * @param position the position on the screen where the cursor will snap to - * @param range how far away from the snap point the cursor can be and still snap to it - */ -public record SnapPoint(Vector2ic position, int range) { - public SnapPoint(int x, int y, int range) { - this(new Vector2i(x, y), range); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/BindContext.java b/src/main/java/dev/isxander/controlify/bindings/BindContext.java deleted file mode 100644 index e6ba87f22..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/BindContext.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.gui.screen.RadialMenuScreen; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.controlify.virtualmouse.VirtualMouseBehaviour; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; -import net.minecraft.resources.Identifier; - -import java.util.HashMap; -import java.util.Map; -import java.util.function.Function; - -public record BindContext(Identifier id, Function isApplicable) { - public static final Map CONTEXTS = new HashMap<>(); - - public static final BindContext UNKNOWN = register( - "unknown", - mc -> true - ); - - public static final BindContext IN_GAME = register( - "in_game", - mc -> MinecraftUtil.getScreen() == null && mc.level != null && mc.player != null - ); - - public static final BindContext ANY_SCREEN = register( - "screen", - mc -> MinecraftUtil.getScreen() != null - ); - - public static final BindContext REGULAR_SCREEN = register( - "regular_screen", - mc -> MinecraftUtil.getScreen() != null - && !Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled() - ); - - public static final BindContext CONTAINER = register( - "container", - mc -> MinecraftUtil.getScreen() instanceof AbstractContainerScreen - ); - - public static final BindContext V_MOUSE_CURSOR = register( - "vmouse_cursor", - mc -> MinecraftUtil.getScreen() != null - && ScreenProcessorProvider.provide(MinecraftUtil.getScreen()).virtualMouseBehaviour().hasCursor() - && Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled() - ); - - public static final BindContext V_MOUSE_COMPAT = register( - "vmouse_compat", - mc -> MinecraftUtil.getScreen() != null - && ScreenProcessorProvider.provide(MinecraftUtil.getScreen()).virtualMouseBehaviour() == VirtualMouseBehaviour.ENABLED - && Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled() - ); - - public static final BindContext RADIAL_MENU = register( - "radial_menu", - mc -> MinecraftUtil.getScreen() instanceof RadialMenuScreen - ); - - private static BindContext register(String path, Function predicate) { - var context = new BindContext(CUtil.rl(path), predicate); - CONTEXTS.put(context.id, context); - return context; - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/ControlifyBindApiImpl.java b/src/main/java/dev/isxander/controlify/bindings/ControlifyBindApiImpl.java deleted file mode 100644 index 111686670..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/ControlifyBindApiImpl.java +++ /dev/null @@ -1,126 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -import dev.isxander.controlify.api.bind.ControlifyBindApi; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.bindings.output.KeyMappingEmulationOutput; -import dev.isxander.controlify.controller.ControllerEntity; -import net.minecraft.client.KeyMapping; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.function.BooleanSupplier; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.stream.Stream; - -public class ControlifyBindApiImpl implements ControlifyBindApi { - public static final ControlifyBindApiImpl INSTANCE = new ControlifyBindApiImpl(); - - private final List bindEntries = new ArrayList<>(); - private final Map> keyMappingCorrelations = new HashMap<>(); - - private boolean locked; - - @Override - public InputBindingSupplier registerBinding(RegistryCallback callback) { - return registerBinding(callback, c -> true); - } - - @Override - public InputBindingSupplier registerBinding(RegistryCallback callback, Predicate filter) { - checkLocked(); - - var builder = new InputBindingBuilderImpl(); - - callback.apply(builder); - - Function finaliser = builder::build; - - Identifier bindId = builder.getIdAndLock(); - - this.bindEntries.add(new RegistryEntry(filter, finaliser, builder.getKeyEmulation(), builder.getKeyEmulationToggle(), bindId)); - - for (KeyMapping key : builder.getKeyCorrelations()) { - keyMappingCorrelations.computeIfAbsent(key, k -> new ArrayList<>()).add(createSupplier(bindId)); - } - - return createSupplier(bindId); - } - - @Override - public List getKeyCorrelation(KeyMapping key) { - return Optional.ofNullable(this.keyMappingCorrelations.get(key)).orElse(List.of()); - } - - @Override - public void registerBindContext(BindContext context) { - BindContext.CONTEXTS.put(context.id(), context); - } - - private void checkLocked() { - if (locked) - throw new IllegalStateException("Registry is locked. Cannot add bind now."); - } - - /** - * Provides all bindings for a given controller. - * @param controller The controller to provide bindings for. Can be null to get a list of all bindings. - * @return A list of input bindings. - */ - public List provideBindsForController(@Nullable ControllerEntity controller) { - List bindings = new ArrayList<>(); - - for (RegistryEntry entry : bindEntries) { - if (controller != null && !entry.filter().test(controller)) - continue; - - InputBindingImpl binding = entry.builder().apply(controller); - - if (controller != null && entry.emulation() != null) { - BooleanSupplier emulationToggle = null; - if (entry.emulationToggle() != null) { - emulationToggle = () -> entry.emulationToggle().apply(controller); - } - - binding.addDigitalOutput( - InputBinding.KEY_EMULATION, - new KeyMappingEmulationOutput(controller, binding, entry.emulation(), emulationToggle) - ); - } - - bindings.add(binding); - } - - return bindings; - } - - @Override - public InputBindingSupplier createSupplier(Identifier bindingId) { - return new InputBindingSupplierImpl(bindingId); - } - - public void lock() { - this.locked = true; - } - - @Override - public Stream getAllBindIds() { - return this.bindEntries.stream().map(RegistryEntry::id); - } - - private record RegistryEntry( - Predicate filter, - Function<@Nullable ControllerEntity, InputBindingImpl> builder, - KeyMapping emulation, - Function emulationToggle, - Identifier id - ) {} -} diff --git a/src/main/java/dev/isxander/controlify/bindings/ControlifyBindings.java b/src/main/java/dev/isxander/controlify/bindings/ControlifyBindings.java deleted file mode 100644 index f1b3fd418..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/ControlifyBindings.java +++ /dev/null @@ -1,484 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -import dev.isxander.controlify.api.bind.ControlifyBindApi; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.platform.client.PlatformClientUtil; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.ChatFormatting; -import net.minecraft.client.KeyMapping; -import net.minecraft.client.Minecraft; -import net.minecraft.client.Options; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; - -import java.util.LinkedHashMap; -import java.util.Map; - -public final class ControlifyBindings { - static final String MODDED_BIND_NAMESPACE = "controlify_modded"; - - private static final Options options = Minecraft.getInstance().options; - - public static final Component MOVEMENT_CATEGORY = KeyMapping.Category.MOVEMENT.label(); - public static final Component GAMEPLAY_CATEGORY = KeyMapping.Category.GAMEPLAY.label(); - public static final Component INVENTORY_CATEGORY = KeyMapping.Category.INVENTORY.label(); - public static final Component CREATIVE_CATEGORY = KeyMapping.Category.CREATIVE.label(); - public static final Component MISC_CATEGORY = KeyMapping.Category.MISC.label(); - public static final Component DEBUG_CATEGORY = Component.translatable("controlify.binding_category.debug"); - public static final Component GUI_CATEGORY = Component.translatable("controlify.binding_category.gui"); - public static final Component RADIAL_CATEGORY = Component.translatable("controlify.gui.radial_menu"); - public static final Component VMOUSE_CATEGORY = Component.translatable("controlify.binding_category.vmouse"); - - public static final InputBindingSupplier WALK_FORWARD = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "walk_forward") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier WALK_BACKWARD = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "walk_backward") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier WALK_LEFT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "strafe_left") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier WALK_RIGHT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "strafe_right") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - - public static final InputBindingSupplier LOOK_UP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "look_up") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier LOOK_DOWN = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "look_down") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier LOOK_LEFT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "look_left") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier LOOK_RIGHT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "look_right") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - - public static final InputBindingSupplier GYRO_BUTTON = ControlifyBindApi.get().registerBinding( - builder -> builder - .id("controlify", "gyro_button") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME), - c -> c.gyro().isPresent() - ); - - public static final InputBindingSupplier JUMP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "jump") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier SPRINT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "sprint") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME) - .keyEmulation(options.keySprint, c -> c.settings().generic.toggleSprint)); - public static final InputBindingSupplier SNEAK = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "sneak") - .category(MOVEMENT_CATEGORY) - .allowedContexts(BindContext.IN_GAME) - .addKeyCorrelation(options.keyShift)); - - public static final InputBindingSupplier ATTACK = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "attack") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME) - .keyEmulation(options.keyAttack)); - public static final InputBindingSupplier USE = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "use") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME) - .keyEmulation(options.keyUse)); - public static final InputBindingSupplier DROP_INGAME = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "drop") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier DROP_STACK = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "drop_stack") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier PAUSE = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "pause") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME, BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier CHANGE_PERSPECTIVE = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "change_perspective") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier SWAP_HANDS = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "swap_hands") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier NEXT_SLOT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "next_slot") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier PREV_SLOT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "prev_slot") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_SELECT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_item_select_radial") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME, BindContext.RADIAL_MENU)); - public static final InputBindingSupplier HOTBAR_SLOT_1 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_1") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_2 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_2") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_3 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_3") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_4 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_4") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_5 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_5") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_6 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_6") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_7 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_7") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_8 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_8") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_SLOT_9 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_slot_9") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier GAME_MODE_SWITCHER = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "game_mode_switcher") - .category(GAMEPLAY_CATEGORY) - .allowedContexts(BindContext.IN_GAME, BindContext.RADIAL_MENU)); - - public static final InputBindingSupplier INVENTORY = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "inventory") - .category(INVENTORY_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier INV_SELECT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "inv_select") - .category(INVENTORY_CATEGORY) - .allowedContexts(BindContext.CONTAINER)); - public static final InputBindingSupplier INV_QUICK_MOVE = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "inv_quick_move") - .category(INVENTORY_CATEGORY) - .allowedContexts(BindContext.CONTAINER)); - public static final InputBindingSupplier INV_TAKE_HALF = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "inv_take_half") - .category(INVENTORY_CATEGORY) - .allowedContexts(BindContext.CONTAINER)); - public static final InputBindingSupplier DROP_INVENTORY = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "drop_inventory") - .category(INVENTORY_CATEGORY) - .allowedContexts(BindContext.CONTAINER, BindContext.REGULAR_SCREEN)); - - public static final InputBindingSupplier PICK_BLOCK = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "pick_block") - .category(CREATIVE_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier PICK_BLOCK_NBT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "pick_block_nbt") - .category(CREATIVE_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier HOTBAR_LOAD_RADIAL = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_load_radial") - .category(CREATIVE_CATEGORY) - .allowedContexts(BindContext.IN_GAME, BindContext.RADIAL_MENU)); - public static final InputBindingSupplier HOTBAR_SAVE_RADIAL = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "hotbar_save_radial") - .category(CREATIVE_CATEGORY) - .allowedContexts(BindContext.IN_GAME, BindContext.RADIAL_MENU)); - - public static final InputBindingSupplier OPEN_CHAT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "open_chat") - .category(MISC_CATEGORY) - .allowedContexts(BindContext.IN_GAME) - .keyEmulation(options.keyChat)); - public static final InputBindingSupplier TOGGLE_HUD_VISIBILITY = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "toggle_hud_visibility") - .category(MISC_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier SHOW_PLAYER_LIST = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "show_player_list") - .category(MISC_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier TAKE_SCREENSHOT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "take_screenshot") - .category(MISC_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - - public static final InputBindingSupplier DEBUG_RADIAL = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "debug_radial") - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME, BindContext.RADIAL_MENU)); - public static final InputBindingSupplier TOGGLE_DEBUG_MENU = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "toggle_debug_menu") - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier TOGGLE_DEBUG_MENU_FPS = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "toggle_debug_menu_fps") - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier TOGGLE_DEBUG_MENU_NET = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "toggle_debug_menu_net") - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier TOGGLE_DEBUG_MENU_PROF = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "toggle_debug_menu_prof") - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier DEBUG_RELOAD_CHUNKS = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "debug_reload_chunks") - .name(Component.translatable("controlify.radial.debug.reload_chunks")) - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier DEBUG_TOGGLE_CHUNK_BORDERS = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "debug_toggle_chunk_borders") - .name(Component.translatable("controlify.radial.debug.chunk_borders")) - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier DEBUG_TOGGLE_ADVANCED_TOOLTIPS = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "debug_toggle_advanced_tooltips") - .name(Component.translatable("controlify.radial.debug.advanced_tooltips")) - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier DEBUG_TOGGLE_ENTITY_HITBOXES = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "debug_toggle_entity_hitboxes") - .name(Component.translatable("controlify.radial.debug.entity_hitboxes")) - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier DEBUG_RELOAD_RESOURCE_PACKS = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "debug_reload_resource_packs") - .name(Component.translatable("controlify.radial.debug.reload_packs")) - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier DEBUG_CLEAR_CHAT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "debug_clear_chat") - .name(Component.translatable("controlify.radial.debug.clear_chat")) - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier DEBUG_START_STOP_PROFILING = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "debug_start_stop_profiling") - .name(Component.translatable("controlify.radial.debug.profile")) - .category(DEBUG_CATEGORY) - .allowedContexts(BindContext.IN_GAME)); - public static final InputBindingSupplier GUI_PRESS = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_press") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_BACK = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_back") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.ANY_SCREEN)); - public static final InputBindingSupplier GUI_NEXT_TAB = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_next_tab") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.ANY_SCREEN)); - public static final InputBindingSupplier GUI_PREV_TAB = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_prev_tab") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.ANY_SCREEN)); - public static final InputBindingSupplier GUI_ABSTRACT_ACTION_1 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_abstract_action_1") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_ABSTRACT_ACTION_2 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_abstract_action_2") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_ABSTRACT_ACTION_3 = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_abstract_action_3") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_NAVI_UP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_navi_up") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_NAVI_DOWN = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_navi_down") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_NAVI_LEFT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_navi_left") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_NAVI_RIGHT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_navi_right") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_SECONDARY_NAVI_UP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_secondary_navi_up") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.CONTAINER, BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_SECONDARY_NAVI_DOWN = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_secondary_navi_down") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.CONTAINER, BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_SECONDARY_NAVI_LEFT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_secondary_navi_left") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.CONTAINER, BindContext.REGULAR_SCREEN)); - public static final InputBindingSupplier GUI_SECONDARY_NAVI_RIGHT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "gui_secondary_navi_right") - .category(GUI_CATEGORY) - .allowedContexts(BindContext.CONTAINER, BindContext.REGULAR_SCREEN)); - @Deprecated - public static final InputBindingSupplier CYCLE_OPT_FORWARD = GUI_SECONDARY_NAVI_RIGHT; - @Deprecated - public static final InputBindingSupplier CYCLE_OPT_BACKWARD = GUI_SECONDARY_NAVI_LEFT; - - public static final InputBindingSupplier RADIAL_MENU = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "radial_menu") - .category(RADIAL_CATEGORY) - .allowedContexts(BindContext.IN_GAME, BindContext.RADIAL_MENU)); - public static final InputBindingSupplier RADIAL_AXIS_UP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "radial_axis_up") - .category(RADIAL_CATEGORY) - .allowedContexts(BindContext.RADIAL_MENU)); - public static final InputBindingSupplier RADIAL_AXIS_DOWN = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "radial_axis_down") - .category(RADIAL_CATEGORY) - .allowedContexts(BindContext.RADIAL_MENU)); - public static final InputBindingSupplier RADIAL_AXIS_LEFT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "radial_axis_left") - .category(RADIAL_CATEGORY) - .allowedContexts(BindContext.RADIAL_MENU)); - public static final InputBindingSupplier RADIAL_AXIS_RIGHT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "radial_axis_right") - .category(RADIAL_CATEGORY) - .allowedContexts(BindContext.RADIAL_MENU)); - - public static final InputBindingSupplier VMOUSE_MOVE_UP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_move_up") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_MOVE_DOWN = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_move_down") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_MOVE_LEFT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_move_left") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_MOVE_RIGHT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_move_right") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_SNAP_UP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_snap_up") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_SNAP_DOWN = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_snap_down") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_SNAP_LEFT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_snap_left") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_SNAP_RIGHT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_snap_right") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_LCLICK = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_lclick") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_RCLICK = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_rclick") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_SHIFT_CLICK = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_shift_click") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_SCROLL_DOWN = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_scroll_down") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_SCROLL_UP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_scroll_up") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_SHIFT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_shift") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_PAGE_NEXT = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_page_next") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_PAGE_PREV = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_page_prev") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_PAGE_DOWN = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_page_down") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_PAGE_UP = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_page_up") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_COMPAT, BindContext.V_MOUSE_CURSOR)); - public static final InputBindingSupplier VMOUSE_TOGGLE = ControlifyBindApi.get().registerBinding(builder -> builder - .id("controlify", "vmouse_toggle") - .category(VMOUSE_CATEGORY) - .allowedContexts(BindContext.V_MOUSE_CURSOR, BindContext.V_MOUSE_COMPAT, BindContext.ANY_SCREEN)); - - private static final Map MODDED_BINDS = new LinkedHashMap<>(); - - public static void registerModdedBindings() { - for (KeyMapping keyMapping : PlatformClientUtil.getModdedKeyMappings()) { - if (!ControlifyBindApi.get().getKeyCorrelation(keyMapping).isEmpty()) - continue; - - try { - var idPath = keyMapping.getName() - .toLowerCase() - .replaceAll("[^a-z0-9/._-]", "_") - .trim(); - - var identifier = Identifier.fromNamespaceAndPath(MODDED_BIND_NAMESPACE, idPath); - - InputBindingSupplier binding = ControlifyBindApi.get().registerBinding(builder -> builder - .id(identifier) - .name(Component.translatable(keyMapping.getName())) - .description(Component.translatable("controlify.custom_binding.vanilla_description").withStyle(ChatFormatting.GRAY)) - .category(keyMapping.getCategory().label()) - .allowedContexts(BindContext.IN_GAME) - .keyEmulation(keyMapping)); - - MODDED_BINDS.put(keyMapping, binding); - } catch (Exception e) { - CUtil.LOGGER.error("Failed to automatically register modded keybind: {}", keyMapping.getName(), e); - } - } - } - - private ControlifyBindings() { - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/InputBindingBuilderImpl.java b/src/main/java/dev/isxander/controlify/bindings/InputBindingBuilderImpl.java deleted file mode 100644 index e4f9c7634..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/InputBindingBuilderImpl.java +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.bind.InputBindingBuilder; -import dev.isxander.controlify.bindings.defaults.DefaultBindProvider; -import dev.isxander.controlify.bindings.input.EmptyInput; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.id.ControllerType; -import net.minecraft.client.KeyMapping; -import net.minecraft.locale.Language; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.apache.commons.lang3.Validate; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.function.Function; -import java.util.function.Supplier; - -public class InputBindingBuilderImpl implements InputBindingBuilder { - private @Nullable Identifier id; - private @Nullable Component category; - private @Nullable Component customName, customDescription; - private @Nullable Input defaultInput; - private final Set allowedContexts = new HashSet<>(); - private final Set keyCorrelations = new HashSet<>(); - private KeyMapping keyEmulation = null; - private Function keyEmulationToggle = null; - - @Deprecated(forRemoval = true) - private boolean isRadialCandidate = false; - - private boolean locked; - - @Override - public InputBindingBuilder id(@NotNull Identifier rl) { - checkLocked(); - - this.id = rl; - return this; - } - - @Override - public InputBindingBuilder id(@NotNull String namespace, @NotNull String path) { - return this.id(Identifier.fromNamespaceAndPath(namespace, path)); - } - - @Override - public InputBindingBuilder category(@NotNull Component text) { - checkLocked(); - - this.category = text; - return this; - } - - @Override - public InputBindingBuilder name(@NotNull Component text) { - checkLocked(); - - this.customName = text; - return this; - } - - @Override - public InputBindingBuilder description(@NotNull Component text) { - checkLocked(); - - this.customDescription = text; - return this; - } - - @Override - public InputBindingBuilder defaultInput(@Nullable Input input) { - checkLocked(); - - this.defaultInput = input; - return this; - } - - @Override - public InputBindingBuilder allowedContexts(@NotNull BindContext @Nullable ... contexts) { - checkLocked(); - - if (contexts != null) - this.allowedContexts.addAll(List.of(contexts)); - return this; - } - - @Override - public InputBindingBuilder addKeyCorrelation(@NotNull KeyMapping keyMapping) { - checkLocked(); - - this.keyCorrelations.add(keyMapping); - return this; - } - - @Override - public InputBindingBuilder keyEmulation(@NotNull KeyMapping keyMapping, @Nullable Function toggleCondition) { - checkLocked(); - - this.keyEmulation = keyMapping; - this.keyEmulationToggle = toggleCondition; - this.addKeyCorrelation(keyMapping); - return this; - } - - @Override - public InputBindingBuilder keyEmulation(@NotNull KeyMapping keyMapping) { - return keyEmulation(keyMapping, null); - } - - @Override - @Deprecated(forRemoval = true) - @SuppressWarnings("removal") - public InputBindingBuilder radialCandidate(boolean isCandidate) { - this.isRadialCandidate = isCandidate; - return this; - } - - public InputBindingImpl build(@Nullable ControllerEntity controller) { - Validate.isTrue(locked, "Tried to build builder before it was locked."); - - Identifier controllerType = controller != null - ? controller.info().type().namespace() - : ControllerType.DEFAULT.namespace(); - - Component name = createDefaultString(controllerType, null, false); - if (customName != null) name = customName; - - Component description = createDefaultString(controllerType, "desc", true); - if (customDescription != null) description = customDescription; - if (description == null) description = Component.empty(); - - Supplier defaultSupplier = () -> { - // retrieve every tick so the bind provider isn't cached after a resource reload - DefaultBindProvider provider = Controlify.instance().defaultBindManager().getDefaultBindProvider( - controllerType - ); - - Input input = provider.getDefaultBind(id); - if (input == null) input = defaultInput; - if (input == null) input = EmptyInput.INSTANCE; - - return input; - }; - - return new InputBindingImpl( - controller != null ? controller.input().orElse(null) : null, - controllerType, - id, - name, - description, - category, - defaultSupplier, - allowedContexts, - isRadialCandidate - ); - } - - @NotNull public Identifier getIdAndLock() { - checkLocked(); - - locked = true; - Validate.notNull(this.id, "Must call `.id(Identifier)` on builder!"); - Validate.notNull(this.category, "Must call `.category(Component)` on builder %s!".formatted(this.id)); - - return this.id; - } - - public Set getKeyCorrelations() { - return this.keyCorrelations; - } - - public @Nullable KeyMapping getKeyEmulation() { - return this.keyEmulation; - } - - public @Nullable Function getKeyEmulationToggle() { - return this.keyEmulationToggle; - } - - private void checkLocked() { - Validate.isTrue(!locked, "Tried to modify binding builder after is has been locked!"); - } - - private Component createDefaultString(Identifier controllerType, @Nullable String suffix, boolean notExistToNull) { - Objects.requireNonNull(id); - - String typeSpecificKey = controllerType.toLanguageKey("controlify.binding", id.toLanguageKey()); - if (suffix != null) typeSpecificKey += "." + suffix; - if (Language.getInstance().has(typeSpecificKey)) { - return Component.translatable(typeSpecificKey); - } - - String genericKey = id.toLanguageKey("controlify.binding"); - if (suffix != null) genericKey += "." + suffix; - - if (notExistToNull && !Language.getInstance().has(genericKey)) - return null; - - return Component.translatable(genericKey); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java b/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java deleted file mode 100644 index 58b9be40e..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java +++ /dev/null @@ -1,351 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.bindings.output.*; -import dev.isxander.controlify.controller.input.ControllerStateView; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.utils.ResizableRingBuffer; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.function.Consumer; -import java.util.function.Supplier; - -public class InputBindingImpl implements InputBinding { - private final Identifier id; - private final Component name, description, category; - private final boolean isRadialCandidate; - /** - * Fallback bound input used only when inputComponent is null (i.e., bindings not attached to a controller). - * When inputComponent is present, the bound input is read/written directly from the config. - */ - private Input fallbackBoundInput; - private final Supplier defaultBindSupplier; - private final Set contexts; - private final @Nullable InputComponent inputComponent; - private final Identifier controllerType; - - private final ResizableRingBuffer stateHistory; - private final Set borrowedAccesses; - - private boolean suppressed; - - private final AnalogueOutput analogueNow; - private final AnalogueOutput analoguePrev; - private final DigitalOutput digitalNow; - private final DigitalOutput digitalThen; - private final DigitalOutput justPressed; - private final DigitalOutput justReleased; - private final DigitalOutput justTapped; - private final GuiPressOutput guiPressOutput; - - private final Map digitalOutputs; - private final Map analogueOutputs; - - private int fakePressState = -1; - - public InputBindingImpl( - @Nullable InputComponent inputComponent, - Identifier controllerType, - Identifier id, - Component name, - Component description, - Component category, - Supplier defaultBindSupplier, - Set contexts, - boolean isRadialCandidate - ) { - this.inputComponent = inputComponent; - this.controllerType = controllerType; - this.id = id; - this.name = name; - this.description = description; - this.category = category; - this.isRadialCandidate = isRadialCandidate; - this.stateHistory = new ResizableRingBuffer<>(2, () -> 0f); - this.fallbackBoundInput = defaultBindSupplier.get(); - this.defaultBindSupplier = defaultBindSupplier; - this.contexts = contexts; - this.borrowedAccesses = new HashSet<>(); - - this.digitalOutputs = new HashMap<>(); - this.analogueOutputs = new HashMap<>(); - - this.analogueNow = addAnalogueOutput(ANALOGUE_NOW, new SimpleAnalogueOutput(this, 0)); - this.analoguePrev = addAnalogueOutput(ANALOGUE_PREV, new SimpleAnalogueOutput(this, 1)); - this.digitalNow = addDigitalOutput(DIGITAL_NOW, new SimpleDigitalOutput(this, 0)); - this.digitalThen = addDigitalOutput(DIGITAL_PREV, new SimpleDigitalOutput(this, 1)); - this.justPressed = addDigitalOutput(JUST_PRESSED, new JustPressedOutput(this)); - this.justReleased = addDigitalOutput(JUST_RELEASED, new JustReleasedOutput(this)); - this.justTapped = addDigitalOutput(JUST_TAPPED, new JustTappedOutput(this)); - this.guiPressOutput = addDigitalOutput(GUI_PRESSED, new GuiPressOutput(this)); - } - - @Override - public Identifier id() { - return this.id; - } - - @Override - public Component name() { - return this.name; - } - - @Override - public Component description() { - return this.description; - } - - @Override - public Component category() { - return this.category; - } - - @Override - public Component inputGlyph() { - return Controlify.instance().inputFontMapper().getComponentFromInputs( - controllerType, - boundInput().getRelevantInputs() - ); - } - - @Deprecated(forRemoval = true) - @SuppressWarnings("removal") - @Override - public boolean isRadialCandidate() { - return isRadialCandidate; - } - - @Override - public StateAccess createStateAccess(int historyRequired) { - return createStateAccess(historyRequired, null); - } - - @Override - public StateAccess createStateAccess(int historyRequired, Consumer pushEvent) { - if (historyRequired > this.stateHistory.size()) { - this.stateHistory.setSize(historyRequired); - } - - StateAccessImpl access = new StateAccessImpl(historyRequired, pushEvent); - borrowedAccesses.add(access); - - return access; - } - - @Override - public void returnStateAccess(StateAccess stateAccess) { - if (stateAccess instanceof StateAccessImpl accessImpl) { - boolean removed = this.borrowedAccesses.remove(accessImpl); - accessImpl.retire(); - - if (removed) { - OptionalInt newMaxSize = this.borrowedAccesses.stream().mapToInt(StateAccessImpl::maxHistory).max(); - newMaxSize.ifPresent(this.stateHistory::setSize); - } - } else { - throw new IllegalStateException("Unknown implementation of state access"); - } - } - - @Override - public void pushState(ControllerStateView state) { - if (!this.contexts.isEmpty()) { - Set thisTickContexts = Controlify.instance().thisTickBindContexts(); - this.suppressed = this.contexts.stream().noneMatch(thisTickContexts::contains); - } else { - this.suppressed = false; - } - - float analogue = this.boundInput().state(state); - - switch (fakePressState) { - case 0 -> analogue = 0; - case 1,2 -> analogue = 1; - case 3 -> analogue = 0; - } - if (fakePressState >= 0) { - if (fakePressState == 3) - fakePressState = -1; - else - fakePressState++; - } - - this.stateHistory.push(analogue); - borrowedAccesses.forEach(StateAccessImpl::onPush); - } - - @Override - public void fakePress() { - fakePressState = 0; - } - - @Override - public void setBoundInput(Input input) { - if (inputComponent != null) { - var settings = inputComponent.settings(); - // If keepDefaultBindings is false and the input equals the default, - // remove from config so it won't be serialized (allowing future default changes to take effect) - if (!settings.keepDefaultBindings && input.equals(defaultInput())) { - settings.bindings.bindings.remove(id); - } else { - // Write directly to the config - all bindings sharing the same config will see this change - settings.bindings.bindings.put(id, input); - } - } else { - // No inputComponent means this binding isn't attached to a controller, use fallback - this.fallbackBoundInput = input; - } - Controlify.instance().config().markDirty(); - } - - @Override - public Input boundInput() { - if (inputComponent != null) { - // Read directly from the config - ensures all bindings sharing the same config see the same value - Input configInput = inputComponent.settings().bindings.bindings.get(id); - if (configInput != null) { - return configInput; - } - // Not in config means it's at the default (require keepDefaultBindings is false) - // Return the dynamic default so all controllers stay in sync - return defaultInput(); - } - // Fallback: use local value for bindings without inputComponent - return this.fallbackBoundInput; - } - - @Override - public Input defaultInput() { - return this.defaultBindSupplier.get(); - } - - @Override - public Set contexts() { - return this.contexts; - } - - @Override - public float analogueNow() { - return this.analogueNow.get(); - } - - @Override - public float analoguePrev() { - return this.analoguePrev.get(); - } - - @Override - public boolean digitalNow() { - return this.digitalNow.get(); - } - - @Override - public boolean digitalPrev() { - return this.digitalThen.get(); - } - - @Override - public boolean justPressed() { - return this.justPressed.get(); - } - - @Override - public boolean justReleased() { - return this.justReleased.get(); - } - - @Override - public boolean justTapped() { - return this.justTapped.get(); - } - - @Override - public GuiPressOutput guiPressed() { - return this.guiPressOutput; - } - - @Override - public T addDigitalOutput(Identifier id, T output) { - this.digitalOutputs.put(id, output); - return output; - } - - @Override - public T getDigitalOutput(Identifier id) { - return (T) this.digitalOutputs.get(id); - } - - @Override - public T addAnalogueOutput(Identifier id, T output) { - this.analogueOutputs.put(id, output); - return output; - } - - @Override - public T getAnalogueOutput(Identifier id) { - return (T) this.analogueOutputs.get(id); - } - - private class StateAccessImpl implements StateAccess { - private final int maxHistory; - private boolean valid; - private final @Nullable Consumer pushListener; - - public StateAccessImpl(int maxHistory, @Nullable Consumer pushListener) { - this.maxHistory = maxHistory; - this.valid = true; - this.pushListener = pushListener; - } - - @Override - public float analogue(int history) { - if (!valid) throw new IllegalStateException("Tried to access state from returned access!"); - if (history > maxHistory) throw new IllegalStateException("Overflowing history!"); - - return InputBindingImpl.this.stateHistory.tail(history); - } - - @Override - public boolean digital(int history) { - float threshold = inputComponent != null - ? inputComponent.settings().buttonActivationThreshold - : 0.5f; - return analogue(history) > threshold; - } - - @Override - public boolean isSuppressed() { - return suppressed; - } - - @Override - public boolean isValid() { - return valid; - } - - @Override - public int maxHistory() { - return this.maxHistory; - } - - public void onPush() { - if (this.pushListener != null) - this.pushListener.accept(this); - } - - public void retire() { - this.valid = false; - } - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/InputBindingSupplierImpl.java b/src/main/java/dev/isxander/controlify/bindings/InputBindingSupplierImpl.java deleted file mode 100644 index baa7d638c..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/InputBindingSupplierImpl.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.font.BindingFontHelper; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; - -public record InputBindingSupplierImpl(Identifier bindId) implements InputBindingSupplier { - @Override - public @Nullable InputBinding onOrNull(ControllerEntity controller) { - return controller.input().map(c -> c.getBinding(bindId)).orElse(null); - } - - @Override - public Component inputGlyph() { - return BindingFontHelper.binding(this.bindId()); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/KeyMappingHandle.java b/src/main/java/dev/isxander/controlify/bindings/KeyMappingHandle.java deleted file mode 100644 index 5b430074e..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/KeyMappingHandle.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -import dev.isxander.controlify.controller.ControllerEntity; - -import java.util.function.BooleanSupplier; - -public interface KeyMappingHandle { - void controlify$setPressed(boolean isDown); - - void controlify$addToggleCondition(ControllerEntity controller, BooleanSupplier condition); -} diff --git a/src/main/java/dev/isxander/controlify/bindings/RadialIcons.java b/src/main/java/dev/isxander/controlify/bindings/RadialIcons.java deleted file mode 100644 index 3124ff4d5..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/RadialIcons.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.core.Holder; -import net.minecraft.resources.Identifier; -import net.minecraft.world.effect.MobEffect; -import net.minecraft.world.item.Item; - -/// @deprecated Radial icons are now data driven. -/// [Check out the wiki](https://moddedmc.wiki/en/project/controlify/latest/docs/resource-packs/radial-icons) -@Deprecated(forRemoval = true) -public final class RadialIcons { - - private RadialIcons() { - } - - @Deprecated(forRemoval = true) - public static Identifier getItem(Item item) { - return CUtil.rl("radial_icons_are_data_driven"); - } - - @Deprecated(forRemoval = true) - public static Identifier getEffect(Holder effect) { - return CUtil.rl("radial_icons_are_data_driven"); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/StateAccess.java b/src/main/java/dev/isxander/controlify/bindings/StateAccess.java deleted file mode 100644 index df2b7bfc6..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/StateAccess.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings; - -public interface StateAccess { - float analogue(int history); - - boolean digital(int history); - - boolean isSuppressed(); - - boolean isValid(); - - int maxHistory(); -} diff --git a/src/main/java/dev/isxander/controlify/bindings/defaults/DefaultBindProvider.java b/src/main/java/dev/isxander/controlify/bindings/defaults/DefaultBindProvider.java deleted file mode 100644 index ff97f57a7..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/defaults/DefaultBindProvider.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.defaults; - -import dev.isxander.controlify.bindings.input.Input; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; - -public interface DefaultBindProvider { - @Nullable Input getDefaultBind(Identifier bindId); - - DefaultBindProvider EMPTY = bind -> null; -} diff --git a/src/main/java/dev/isxander/controlify/bindings/defaults/MapBackedDefaultBindProvider.java b/src/main/java/dev/isxander/controlify/bindings/defaults/MapBackedDefaultBindProvider.java deleted file mode 100644 index 229c59cde..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/defaults/MapBackedDefaultBindProvider.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.defaults; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.Keyable; -import com.mojang.serialization.MapCodec; -import dev.isxander.controlify.api.bind.ControlifyBindApi; -import dev.isxander.controlify.bindings.input.Input; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; - -import java.util.Map; - -public record MapBackedDefaultBindProvider(Map map) implements DefaultBindProvider { - public static final MapCodec MAP_CODEC = Codec.simpleMap( - Identifier.CODEC, Input.CODEC, - Keyable.forStrings(() -> ControlifyBindApi.get().getAllBindIds().map(Identifier::toString)) - ).xmap(MapBackedDefaultBindProvider::new, MapBackedDefaultBindProvider::map); - - @Override - public @Nullable Input getDefaultBind(Identifier bindId) { - return map.get(bindId); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/input/AxisInput.java b/src/main/java/dev/isxander/controlify/bindings/input/AxisInput.java deleted file mode 100644 index 3045090e1..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/input/AxisInput.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.input; - -import com.mojang.serialization.MapCodec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.input.ControllerStateView; -import net.minecraft.resources.Identifier; - -import java.util.List; - -public record AxisInput(Identifier axis) implements Input { - public static final String INPUT_ID = "axis"; - - public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( - Identifier.CODEC.fieldOf(INPUT_ID).forGetter(AxisInput::axis) - ).apply(instance, AxisInput::new)); - - @Override - public float state(ControllerStateView state) { - return state.getAxisState(axis); - } - - @Override - public List getRelevantInputs() { - return List.of(axis); - } - - @Override - public InputType type() { - return InputType.AXIS; - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/input/ButtonInput.java b/src/main/java/dev/isxander/controlify/bindings/input/ButtonInput.java deleted file mode 100644 index eac6b4434..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/input/ButtonInput.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.input; - -import com.mojang.serialization.MapCodec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.input.ControllerStateView; -import net.minecraft.resources.Identifier; - -import java.util.List; - -public record ButtonInput(Identifier button) implements Input { - public static final String INPUT_ID = "button"; - - public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( - Identifier.CODEC.fieldOf(INPUT_ID).forGetter(ButtonInput::button) - ).apply(instance, ButtonInput::new)); - - @Override - public float state(ControllerStateView state) { - return state.isButtonDown(button) ? 1 : 0; - } - - @Override - public List getRelevantInputs() { - return List.of(button); - } - - @Override - public InputType type() { - return InputType.BUTTON; - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/input/EmptyInput.java b/src/main/java/dev/isxander/controlify/bindings/input/EmptyInput.java deleted file mode 100644 index 619cd46df..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/input/EmptyInput.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.input; - -import com.mojang.serialization.MapCodec; -import dev.isxander.controlify.controller.input.ControllerStateView; -import net.minecraft.resources.Identifier; - -import java.util.List; - -public record EmptyInput() implements Input { - public static final EmptyInput INSTANCE = new EmptyInput(); - - public static final String INPUT_ID = "empty"; - public static final MapCodec CODEC = MapCodec.unit(() -> INSTANCE); - - @Override - public float state(ControllerStateView state) { - return 0; - } - - @Override - public List getRelevantInputs() { - return List.of(); - } - - @Override - public InputType type() { - return InputType.EMPTY; - } - - public static boolean equals(Input input) { - return input instanceof EmptyInput; - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/input/HatInput.java b/src/main/java/dev/isxander/controlify/bindings/input/HatInput.java deleted file mode 100644 index 7d221d6c2..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/input/HatInput.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.input; - -import com.mojang.serialization.MapCodec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.input.ControllerStateView; -import dev.isxander.controlify.controller.input.HatState; -import net.minecraft.resources.Identifier; - -import java.util.List; - -public record HatInput(Identifier hat, HatState targetState) implements Input { - public static final String INPUT_ID = "hat"; - - public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( - Identifier.CODEC.fieldOf(INPUT_ID).forGetter(HatInput::hat), - HatState.CODEC.fieldOf("target_state").forGetter(HatInput::targetState) - ).apply(instance, HatInput::new)); - - @Override - public float state(ControllerStateView state) { - return state.getHatState(hat) == targetState ? 1 : 0; - } - - @Override - public List getRelevantInputs() { - return List.of(hat); - } - - @Override - public InputType type() { - return InputType.HAT; - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/input/Input.java b/src/main/java/dev/isxander/controlify/bindings/input/Input.java deleted file mode 100644 index a86b55f14..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/input/Input.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.input; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.MapCodec; -import dev.isxander.controlify.controller.input.ControllerStateView; -import net.minecraft.resources.Identifier; - -import java.util.List; - -public interface Input { - MapCodec MAP_CODEC = InputType.createCodec(InputType.TYPES, InputType::codec, Input::type, "type"); - Codec CODEC = MAP_CODEC.codec(); - - float state(ControllerStateView state); - - List getRelevantInputs(); - - InputType type(); -} diff --git a/src/main/java/dev/isxander/controlify/bindings/input/InputType.java b/src/main/java/dev/isxander/controlify/bindings/input/InputType.java deleted file mode 100644 index 5a0b56851..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/input/InputType.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.input; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.MapCodec; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.codec.CExtraCodecs; -import dev.isxander.controlify.utils.codec.StrictEitherMapCodec; -import net.minecraft.util.Util; -import net.minecraft.util.ExtraCodecs; -import net.minecraft.util.StringRepresentable; -import org.jetbrains.annotations.NotNull; - -import java.util.Arrays; -import java.util.function.Function; -import java.util.stream.Stream; - -public record InputType(String id, MapCodec codec) implements StringRepresentable { - public static final InputType BUTTON = new InputType<>(ButtonInput.INPUT_ID, ButtonInput.CODEC); - public static final InputType AXIS = new InputType<>(AxisInput.INPUT_ID, AxisInput.CODEC); - public static final InputType HAT = new InputType<>(HatInput.INPUT_ID, HatInput.CODEC); - public static final InputType EMPTY = new InputType<>(EmptyInput.INPUT_ID, EmptyInput.CODEC); - - public static final InputType[] TYPES = { - InputType.BUTTON, InputType.AXIS, InputType.HAT, InputType.EMPTY - }; - - public static MapCodec createCodec( - T[] types, Function> codecGetter, Function typeGetter, String typeFieldName - ) { - MapCodec fuzzyCodec = CExtraCodecs.fuzzyMap( - Stream.of(types).map(codecGetter).toList(), - obj -> codecGetter.apply(typeGetter.apply(obj)) - ); - - - Codec typeCodec = ExtraCodecs.orCompressed( - CExtraCodecs.stringResolver( - StringRepresentable::getSerializedName, - CUtil.createNameLookup(types, Function.identity()) - ), - ExtraCodecs.idResolverCodec( - Util.createIndexLookup(Arrays.asList(types)), - i -> i >= 0 && i < types.length ? types[i] : null, - -1 - ) - ); - - MapCodec typedCodec = typeCodec.dispatchMap(typeFieldName, typeGetter, codecGetter); - MapCodec eitherCodec = new StrictEitherMapCodec<>(typeFieldName, typedCodec, fuzzyCodec, false); - - return CExtraCodecs.orCompressed(eitherCodec, typedCodec); - } - - @Override - public @NotNull String getSerializedName() { - return this.id(); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/AnalogueOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/AnalogueOutput.java deleted file mode 100644 index fc796b3a2..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/AnalogueOutput.java +++ /dev/null @@ -1,11 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -public interface AnalogueOutput extends BindingOutput { - float get(); -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/DigitalOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/DigitalOutput.java deleted file mode 100644 index 62aa2607c..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/DigitalOutput.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -/** - * An interface that is responsible for consuming a binding's raw - * state and outputting a relevant value from that. - */ -public interface DigitalOutput extends BindingOutput { - boolean get(); -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/GuiPressOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/GuiPressOutput.java deleted file mode 100644 index f1d4b523f..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/GuiPressOutput.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.StateAccess; - -public class GuiPressOutput implements DigitalOutput { - private final StateAccess stateAccess; - - private PressState pressState = PressState.OFF; - - public GuiPressOutput(InputBinding binding) { - this.stateAccess = binding.createStateAccess(2, state -> push()); - } - - @Override - public boolean get() { - return pressState == PressState.JUST_RELEASED; - } - - public PressState getPressState() { - return this.pressState; - } - - private void push() { - boolean held = stateAccess.digital(0); - boolean prevHeld = stateAccess.digital(1); - - // if just pressed, set state to could press in future - // if just released: - // if it could press in future, set to just released - this is the state where get() == true - // if the state is anything else, set the state to off - - if (held) { - if (!prevHeld) { // just started pressing - pressState = PressState.COULD_PRESS_IN_FUTURE; - } - } else { - if (prevHeld && pressState == PressState.COULD_PRESS_IN_FUTURE) { // just released - pressState = PressState.JUST_RELEASED; - } else { - pressState = PressState.OFF; - } - } - - if (stateAccess.isSuppressed()) - pressState = PressState.OFF; - } - - public void onNavigate() { - this.pressState = PressState.OFF; - } - - public enum PressState { - /** Not pressed or navigated since held */ - OFF, - /** This is the state where the action takes place */ - JUST_RELEASED, - /** Currently held down with no navigations since it was pressed */ - COULD_PRESS_IN_FUTURE, - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/JustPressedOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/JustPressedOutput.java deleted file mode 100644 index 852d7e5e2..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/JustPressedOutput.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.StateAccess; - -public class JustPressedOutput implements DigitalOutput { - private final StateAccess stateAccess; - - public JustPressedOutput(InputBinding binding) { - this.stateAccess = binding.createStateAccess(2); - } - - @Override - public boolean get() { - if (stateAccess.isSuppressed()) - return false; - - return stateAccess.digital(0) && !stateAccess.digital(1); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/JustReleasedOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/JustReleasedOutput.java deleted file mode 100644 index 18545b432..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/JustReleasedOutput.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.StateAccess; - -public class JustReleasedOutput implements DigitalOutput { - private final StateAccess stateAccess; - - public JustReleasedOutput(InputBinding binding) { - this.stateAccess = binding.createStateAccess(2); - } - - @Override - public boolean get() { - if (stateAccess.isSuppressed()) - return false; - - return !stateAccess.digital(0) && stateAccess.digital(1); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/JustTappedOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/JustTappedOutput.java deleted file mode 100644 index 060c25276..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/JustTappedOutput.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.StateAccess; - -public class JustTappedOutput implements DigitalOutput { - public static final int DEFAULT_MAX_HOLD_TIME_TICKS = 2; - - private final StateAccess stateAccess; - private final int maxHoldTime; - - public JustTappedOutput(InputBinding binding, int maxHoldTimeTicks) { - this.stateAccess = binding.createStateAccess(maxHoldTimeTicks + 2); - this.maxHoldTime = maxHoldTimeTicks; - } - - public JustTappedOutput(InputBinding binding) { - this(binding, DEFAULT_MAX_HOLD_TIME_TICKS); - } - - @Override - public boolean get() { - if (stateAccess.isSuppressed()) - return false; - - boolean justReleased = stateAccess.digital(0); - if (!justReleased) return false; // still holding or just started to hold, not just tapped - - for (int i = 1; i < maxHoldTime + 2; i++) { // loop through remaining history - boolean state = stateAccess.digital(i); - if (!state) { // if we find one that isn't pressed, we know immediately a tap has taken place - return true; - } - } - - // if we reached the end of the loop without return, - // we know that the maxHoldTime has been exceeded and we did not just tap - return false; - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/KeyMappingEmulationOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/KeyMappingEmulationOutput.java deleted file mode 100644 index c8e6b3c84..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/KeyMappingEmulationOutput.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.KeyMappingHandle; -import dev.isxander.controlify.bindings.StateAccess; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.utils.MinecraftUtil; -import net.minecraft.client.KeyMapping; - -import java.util.function.BooleanSupplier; - -public class KeyMappingEmulationOutput implements DigitalOutput { - private final ControllerEntity controller; - private final StateAccess stateAccess; - private final KeyMapping keyMapping; - - public KeyMappingEmulationOutput(ControllerEntity controller, InputBinding binding, KeyMapping keyMapping, BooleanSupplier toggleCondition) { - this.controller = controller; - this.stateAccess = binding.createStateAccess(2, state -> push()); - this.keyMapping = keyMapping; - - if (toggleCondition != null) { - ((KeyMappingHandle) keyMapping).controlify$addToggleCondition(controller, toggleCondition); - } - } - - @Override - public boolean get() { - throw new IllegalStateException("Should never retrieve output of key mapping emulation!"); - } - - private void push() { - boolean now = stateAccess.digital(0); - boolean prev = stateAccess.digital(1); - - if (ControlifyApi.get().getCurrentController().orElse(null) != controller) - return; // only emulate current controller - - if (MinecraftUtil.getScreen() != null) - return; // minecraft keybinds don't work in gui screens it conflicts - - KeyMappingHandle handle = (KeyMappingHandle) keyMapping; - if (now && !prev) { - handle.controlify$setPressed(true); - } else if (prev && !now) { - handle.controlify$setPressed(false); - } - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/SimpleAnalogueOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/SimpleAnalogueOutput.java deleted file mode 100644 index 9a2d409ad..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/SimpleAnalogueOutput.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.StateAccess; - -public class SimpleAnalogueOutput implements AnalogueOutput { - private final StateAccess stateAccess; - private final int history; - - public SimpleAnalogueOutput(InputBinding binding, int history) { - this.stateAccess = binding.createStateAccess(history + 1); - this.history = history; - } - - @Override - public float get() { - if (stateAccess.isSuppressed()) - return 0f; - - return stateAccess.analogue(history); - } -} diff --git a/src/main/java/dev/isxander/controlify/bindings/output/SimpleDigitalOutput.java b/src/main/java/dev/isxander/controlify/bindings/output/SimpleDigitalOutput.java deleted file mode 100644 index 1eafa83df..000000000 --- a/src/main/java/dev/isxander/controlify/bindings/output/SimpleDigitalOutput.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.bindings.output; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.StateAccess; - -public class SimpleDigitalOutput implements DigitalOutput { - private final StateAccess stateAccess; - private final int history; - - public SimpleDigitalOutput(InputBinding binding, int history) { - this.stateAccess = binding.createStateAccess(history + 1); - this.history = history; - } - - @Override - public boolean get() { - if (stateAccess.isSuppressed()) - return false; - - return stateAccess.digital(history); - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/CompatMixinPlatform.java b/src/main/java/dev/isxander/controlify/compat/CompatMixinPlatform.java similarity index 82% rename from src/main/java/dev/isxander/controlify/compatibility/CompatMixinPlatform.java rename to src/main/java/dev/isxander/controlify/compat/CompatMixinPlatform.java index f73a79e39..5a5333b7b 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/CompatMixinPlatform.java +++ b/src/main/java/dev/isxander/controlify/compat/CompatMixinPlatform.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility; +package dev.isxander.controlify.compat; @FunctionalInterface public interface CompatMixinPlatform { diff --git a/src/main/java/dev/isxander/controlify/compatibility/CompatMixinPlugin.java b/src/main/java/dev/isxander/controlify/compat/CompatMixinPlugin.java similarity index 92% rename from src/main/java/dev/isxander/controlify/compatibility/CompatMixinPlugin.java rename to src/main/java/dev/isxander/controlify/compat/CompatMixinPlugin.java index 25186290a..9668b92ba 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/CompatMixinPlugin.java +++ b/src/main/java/dev/isxander/controlify/compat/CompatMixinPlugin.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility; +package dev.isxander.controlify.compat; import org.objectweb.asm.tree.ClassNode; import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin; @@ -36,14 +36,14 @@ private static Platform loadPlatform() { Loader.FABRIC, loadPlatformImpl( mixinService, - "dev.isxander.controlify.fabric.compatibility.FabricCompatMixinPlatform" + "dev.isxander.controlify.fabric.compat.FabricCompatMixinPlatform" ) ); case "FML" -> new Platform( Loader.NEOFORGE, loadPlatformImpl( mixinService, - "dev.isxander.controlify.neoforge.compatibility.NeoforgeCompatMixinPlatform" + "dev.isxander.controlify.neoforge.compat.NeoforgeCompatMixinPlatform" ) ); default -> throw new IllegalStateException("Unsupported Mixin service: " + mixinService.getName()); diff --git a/src/main/java/dev/isxander/controlify/compatibility/ControlifyCompat.java b/src/main/java/dev/isxander/controlify/compat/ControlifyCompat.java similarity index 62% rename from src/main/java/dev/isxander/controlify/compatibility/ControlifyCompat.java rename to src/main/java/dev/isxander/controlify/compat/ControlifyCompat.java index d50059884..6812f077e 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/ControlifyCompat.java +++ b/src/main/java/dev/isxander/controlify/compat/ControlifyCompat.java @@ -4,10 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility; +package dev.isxander.controlify.compat; -import dev.isxander.controlify.platform.main.PlatformMainUtil; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.compat.fancymenu.FancyMenuCompat; +import dev.isxander.controlify.compat.iris.screenop.IrisScreenBehaviours; +import dev.isxander.controlify.compat.simplevoicechat.SimpleVoiceChatCompat; +import dev.isxander.controlify.compat.sodium.screenop.SodiumScreenBehaviours; +import dev.isxander.controlify.compat.yacl.screenop.YACLScreenBehaviours; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.util.Util; import java.util.HashSet; @@ -23,12 +28,22 @@ public class ControlifyCompat { public static final String SIMPLE_VOICE_CHAT = "voicechat"; public static final String FANCY_MENU = "fancymenu"; + public static void initializeScreenBehaviours() { + YACLScreenBehaviours.initialize(); + //? if sodium { + wrapCompatCall("sodium", SodiumScreenBehaviours::initialize); + //?} + //? if iris { + wrapCompatCall("iris", IrisScreenBehaviours::initialize); + //?} + } + public static void init() { //? if simple_voice_chat { try { wrapCompatCall( SIMPLE_VOICE_CHAT, - dev.isxander.controlify.compatibility.simplevoicechat.SimpleVoiceChatCompat::init + SimpleVoiceChatCompat::init ); } catch (NoClassDefFoundError e) { disabledMods.add(SIMPLE_VOICE_CHAT); @@ -39,7 +54,7 @@ public static void init() { try { wrapCompatCall( FANCY_MENU, - dev.isxander.controlify.compatibility.fancymenu.FancyMenuCompat::registerActions + FancyMenuCompat::registerActions ); } catch (NoClassDefFoundError e) { disabledMods.add(FANCY_MENU); diff --git a/src/main/java/dev/isxander/controlify/compatibility/fancymenu/FancyMenuCompat.java b/src/main/java/dev/isxander/controlify/compat/fancymenu/FancyMenuCompat.java similarity index 86% rename from src/main/java/dev/isxander/controlify/compatibility/fancymenu/FancyMenuCompat.java rename to src/main/java/dev/isxander/controlify/compat/fancymenu/FancyMenuCompat.java index 9c4f78d56..a5846f7b7 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/fancymenu/FancyMenuCompat.java +++ b/src/main/java/dev/isxander/controlify/compat/fancymenu/FancyMenuCompat.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.fancymenu; +package dev.isxander.controlify.compat.fancymenu; //? if fancy_menu { diff --git a/src/main/java/dev/isxander/controlify/compatibility/fancymenu/OpenControlifySettingsAction.java b/src/main/java/dev/isxander/controlify/compat/fancymenu/OpenControlifySettingsAction.java similarity index 85% rename from src/main/java/dev/isxander/controlify/compatibility/fancymenu/OpenControlifySettingsAction.java rename to src/main/java/dev/isxander/controlify/compat/fancymenu/OpenControlifySettingsAction.java index c0d53df54..4dbc8f278 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/fancymenu/OpenControlifySettingsAction.java +++ b/src/main/java/dev/isxander/controlify/compat/fancymenu/OpenControlifySettingsAction.java @@ -4,13 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.fancymenu; +package dev.isxander.controlify.compat.fancymenu; //? if fancy_menu { import de.keksuccino.fancymenu.customization.action.Action; -import dev.isxander.controlify.gui.screen.ControlifySettingsScreen; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.gui.screen.ControlifySettingsScreen; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.network.chat.Component; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; diff --git a/src/main/java/dev/isxander/controlify/compatibility/iris/IrisMixinPlugin.java b/src/main/java/dev/isxander/controlify/compat/iris/IrisMixinPlugin.java similarity index 67% rename from src/main/java/dev/isxander/controlify/compatibility/iris/IrisMixinPlugin.java rename to src/main/java/dev/isxander/controlify/compat/iris/IrisMixinPlugin.java index 7caf475c6..0cca65505 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/iris/IrisMixinPlugin.java +++ b/src/main/java/dev/isxander/controlify/compat/iris/IrisMixinPlugin.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.iris; +package dev.isxander.controlify.compat.iris; -import dev.isxander.controlify.compatibility.CompatMixinPlugin; +import dev.isxander.controlify.compat.CompatMixinPlugin; public class IrisMixinPlugin extends CompatMixinPlugin { @Override diff --git a/src/main/java/dev/isxander/controlify/compat/iris/mixins/BaseOptionElementWidgetMixin.java b/src/main/java/dev/isxander/controlify/compat/iris/mixins/BaseOptionElementWidgetMixin.java new file mode 100644 index 000000000..493d7ff6f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/iris/mixins/BaseOptionElementWidgetMixin.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.iris.mixins; + +//? if iris { + +import dev.isxander.controlify.impl.screenop.behaviours.element.ControlValueAccess; +import net.irisshaders.iris.gui.NavigationController; +import net.irisshaders.iris.gui.element.widget.BaseOptionElementWidget; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +@Mixin(BaseOptionElementWidget.class) +public abstract class BaseOptionElementWidgetMixin implements ControlValueAccess { + @Shadow + public abstract boolean applyPreviousValue(); + + @Shadow + public abstract boolean applyNextValue(); + + @Shadow + protected NavigationController navigation; + + @Override + public void controlify$adjustValue(int direction) { + boolean needsUpdate = direction < 0 ? this.applyPreviousValue() : this.applyNextValue(); + if (needsUpdate) { + this.navigation.refresh(); + } + } +} +//?} diff --git a/src/main/java/dev/isxander/controlify/compat/iris/screenop/IrisScreenBehaviours.java b/src/main/java/dev/isxander/controlify/compat/iris/screenop/IrisScreenBehaviours.java new file mode 100644 index 000000000..7164a09cf --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/iris/screenop/IrisScreenBehaviours.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.iris.screenop; + +//? if iris { + +import dev.isxander.controlify.api.screenop.ElementBehaviours; +import dev.isxander.controlify.api.screenop.ScreenopEvents; +import dev.isxander.controlify.impl.screenop.behaviours.element.ControlValueAccess; +import net.irisshaders.iris.gui.element.widget.BaseOptionElementWidget; + +public final class IrisScreenBehaviours { + private IrisScreenBehaviours() { + } + + public static void initialize() { + ScreenopEvents.REGISTER_ELEMENT_BEHAVIOURS.subscribeForever(event -> + event.registry().register(BaseOptionElementWidget.class, ElementBehaviours.secondarySlider( + (widget, direction) -> ((ControlValueAccess) widget).controlify$adjustValue(direction) + )) + ); + } +} +//?} diff --git a/src/main/java/dev/isxander/controlify/compat/iris/screenop/package-info.java b/src/main/java/dev/isxander/controlify/compat/iris/screenop/package-info.java new file mode 100644 index 000000000..90cb53d30 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/iris/screenop/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.compat.iris.screenop; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/compatibility/rrls/RrlsMixinPlugin.java b/src/main/java/dev/isxander/controlify/compat/rrls/RrlsMixinPlugin.java similarity index 67% rename from src/main/java/dev/isxander/controlify/compatibility/rrls/RrlsMixinPlugin.java rename to src/main/java/dev/isxander/controlify/compat/rrls/RrlsMixinPlugin.java index 0b1b7f937..8cce9903c 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/rrls/RrlsMixinPlugin.java +++ b/src/main/java/dev/isxander/controlify/compat/rrls/RrlsMixinPlugin.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.rrls; +package dev.isxander.controlify.compat.rrls; -import dev.isxander.controlify.compatibility.CompatMixinPlugin; +import dev.isxander.controlify.compat.CompatMixinPlugin; public class RrlsMixinPlugin extends CompatMixinPlugin { @Override diff --git a/src/main/java/dev/isxander/controlify/compatibility/rrls/mixins/HideTypeMixin.java b/src/main/java/dev/isxander/controlify/compat/rrls/mixins/HideTypeMixin.java similarity index 93% rename from src/main/java/dev/isxander/controlify/compatibility/rrls/mixins/HideTypeMixin.java rename to src/main/java/dev/isxander/controlify/compat/rrls/mixins/HideTypeMixin.java index 68d479f57..9f36f5cca 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/rrls/mixins/HideTypeMixin.java +++ b/src/main/java/dev/isxander/controlify/compat/rrls/mixins/HideTypeMixin.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.rrls.mixins; +package dev.isxander.controlify.compat.rrls.mixins; import com.llamalad7.mixinextras.injector.ModifyReturnValue; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import org.spongepowered.asm.mixin.Dynamic; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Pseudo; diff --git a/src/main/java/dev/isxander/controlify/compatibility/rso/RSOMixinPlugin.java b/src/main/java/dev/isxander/controlify/compat/rso/RSOMixinPlugin.java similarity index 69% rename from src/main/java/dev/isxander/controlify/compatibility/rso/RSOMixinPlugin.java rename to src/main/java/dev/isxander/controlify/compat/rso/RSOMixinPlugin.java index 867051aa0..b19ff7677 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/rso/RSOMixinPlugin.java +++ b/src/main/java/dev/isxander/controlify/compat/rso/RSOMixinPlugin.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.rso; +package dev.isxander.controlify.compat.rso; -import dev.isxander.controlify.compatibility.CompatMixinPlugin; +import dev.isxander.controlify.compat.CompatMixinPlugin; public class RSOMixinPlugin extends CompatMixinPlugin { @Override diff --git a/src/main/java/dev/isxander/controlify/compatibility/rso/mixins/AbstractFrameAccessor.java b/src/main/java/dev/isxander/controlify/compat/rso/mixins/AbstractFrameAccessor.java similarity index 90% rename from src/main/java/dev/isxander/controlify/compatibility/rso/mixins/AbstractFrameAccessor.java rename to src/main/java/dev/isxander/controlify/compat/rso/mixins/AbstractFrameAccessor.java index d55e061be..61b5b3986 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/rso/mixins/AbstractFrameAccessor.java +++ b/src/main/java/dev/isxander/controlify/compat/rso/mixins/AbstractFrameAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.rso.mixins; +package dev.isxander.controlify.compat.rso.mixins; //? if reeses_sodium_options { /*import me.flashyreese.mods.reeses_sodium_options.client.gui.frame.AbstractFrame; diff --git a/src/main/java/dev/isxander/controlify/compatibility/rso/mixins/SodiumVideoOptionsScreenMixin.java b/src/main/java/dev/isxander/controlify/compat/rso/mixins/SodiumVideoOptionsScreenMixin.java similarity index 80% rename from src/main/java/dev/isxander/controlify/compatibility/rso/mixins/SodiumVideoOptionsScreenMixin.java rename to src/main/java/dev/isxander/controlify/compat/rso/mixins/SodiumVideoOptionsScreenMixin.java index 7ef7837b3..d77002726 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/rso/mixins/SodiumVideoOptionsScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/compat/rso/mixins/SodiumVideoOptionsScreenMixin.java @@ -4,18 +4,19 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.rso.mixins; +package dev.isxander.controlify.compat.rso.mixins; //? if reeses_sodium_options { /*import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.compatibility.sodium.screenop.SodiumGuiScreenProcessor; -import dev.isxander.controlify.compatibility.sodium.screenop.SodiumScreenOperations; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.compatibility.sodium.screenop.SodiumScreenOperations; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; import me.flashyreese.mods.reeses_sodium_options.client.gui.SodiumVideoOptionsScreen; import me.flashyreese.mods.reeses_sodium_options.client.gui.frame.AbstractFrame; import me.flashyreese.mods.reeses_sodium_options.client.gui.frame.tab.Tab; import me.flashyreese.mods.reeses_sodium_options.client.gui.frame.tab.TabFrame; +import net.caffeinemc.mods.sodium.client.gui.options.control.ControlElement; +import net.caffeinemc.mods.sodium.client.gui.widgets.FlatButtonWidget; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; @@ -26,14 +27,12 @@ import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.ModifyArg; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + import java.util.List; import java.util.Optional; -import net.caffeinemc.mods.sodium.client.gui.widgets.FlatButtonWidget; -import net.caffeinemc.mods.sodium.client.gui.options.control.ControlElement; - @Mixin(SodiumVideoOptionsScreen.class) -public abstract class SodiumVideoOptionsScreenMixin extends Screen implements ScreenProcessorProvider, SodiumScreenOperations { +public abstract class SodiumVideoOptionsScreenMixin extends Screen implements SodiumScreenOperations { @Shadow private FlatButtonWidget applyButton; @Shadow @@ -41,9 +40,6 @@ public abstract class SodiumVideoOptionsScreenMixin extends Screen implements Sc @Shadow private FlatButtonWidget undoButton; - @Unique private final SodiumGuiScreenProcessor controlify$screenProcessor - = new SodiumGuiScreenProcessor((SodiumVideoOptionsScreen) (Object) this, this); - @Unique private TabFrame tabFrame; protected SodiumVideoOptionsScreenMixin(Component title) { @@ -52,8 +48,8 @@ protected SodiumVideoOptionsScreenMixin(Component title) { @Inject(method = "init", at = @At("RETURN")) private void notifyProcessorRebuild(CallbackInfo ci) { - controlify$screenProcessor.onRebuildGUI(); focusOnFirstControl(); + ScreenContextImpl.get(this).onWidgetRebuild(); } @ModifyExpressionValue( @@ -77,25 +73,18 @@ private TabFrame storeBuiltTabFrame(TabFrame original) { private Runnable setInitialFocusOnTabChange(Runnable onSetTab) { return () -> { onSetTab.run(); - Minecraft.getInstance(). - /^? if >=1.21.2 {^/ schedule /^?} else {^/ /^tell ^//^?}^/(this::focusOnFirstControl); + Minecraft.getInstance().schedule(this::focusOnFirstControl); }; } @Unique private void focusOnFirstControl() { AbstractFrame tabContentsFrame = ((TabFrameAccessor) tabFrame).getSelectedFrame(); List> controlElements = ((AbstractFrameAccessor) tabContentsFrame).getControlElements(); - if (!controlElements.isEmpty()) { - System.out.println(controlElements.get(0).getOption().getName().getString()); + if (!controlElements.isEmpty() && !ScreenContextImpl.get(this).virtualMouse().isRequired()) { setInitialFocus(controlElements.get(0)); } } - @Override - public ScreenProcessor screenProcessor() { - return controlify$screenProcessor; - } - @Override public void controlify$nextPage() { var accessor = (TabFrameAccessor) this.tabFrame; diff --git a/src/main/java/dev/isxander/controlify/compatibility/rso/mixins/TabFrameAccessor.java b/src/main/java/dev/isxander/controlify/compat/rso/mixins/TabFrameAccessor.java similarity index 92% rename from src/main/java/dev/isxander/controlify/compatibility/rso/mixins/TabFrameAccessor.java rename to src/main/java/dev/isxander/controlify/compat/rso/mixins/TabFrameAccessor.java index 55189e1f7..1cbd0b1cb 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/rso/mixins/TabFrameAccessor.java +++ b/src/main/java/dev/isxander/controlify/compat/rso/mixins/TabFrameAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.rso.mixins; +package dev.isxander.controlify.compat.rso.mixins; //? if reeses_sodium_options { /*import me.flashyreese.mods.reeses_sodium_options.client.gui.frame.AbstractFrame; diff --git a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SVCMixinPlugin.java b/src/main/java/dev/isxander/controlify/compat/simplevoicechat/SVCMixinPlugin.java similarity index 66% rename from src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SVCMixinPlugin.java rename to src/main/java/dev/isxander/controlify/compat/simplevoicechat/SVCMixinPlugin.java index eeb850f03..8b342da73 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SVCMixinPlugin.java +++ b/src/main/java/dev/isxander/controlify/compat/simplevoicechat/SVCMixinPlugin.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.simplevoicechat; +package dev.isxander.controlify.compat.simplevoicechat; -import dev.isxander.controlify.compatibility.CompatMixinPlugin; +import dev.isxander.controlify.compat.CompatMixinPlugin; public class SVCMixinPlugin extends CompatMixinPlugin { @Override diff --git a/src/main/java/dev/isxander/controlify/compat/simplevoicechat/SimpleVoiceChatCompat.java b/src/main/java/dev/isxander/controlify/compat/simplevoicechat/SimpleVoiceChatCompat.java new file mode 100644 index 000000000..cc66cf4b2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/simplevoicechat/SimpleVoiceChatCompat.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.simplevoicechat; + +//? if simple_voice_chat { + +import de.maxhenkel.voicechat.voice.client.ClientManager; +import de.maxhenkel.voicechat.voice.client.KeyEvents; +import dev.isxander.controlify.api.ControlifyApi; +import dev.isxander.controlify.api.action.BuiltinActionLayers; +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.action.ActionEvents; +import dev.isxander.controlify.api.action.ActionProxyManager; +import dev.isxander.controlify.api.action.ActionSpec; +import dev.isxander.controlify.api.action.ActionSpecRegistry; +import dev.isxander.controlify.api.controller.dualsense.MicLightMode; +import dev.isxander.controlify.compat.simplevoicechat.mixins.KeyEventsAccessor; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; + +public class SimpleVoiceChatCompat { + private static ActionAccessor pttHoldSupplier, pttToggleSupplier; + private static ActionAccessor whisperHoldSupplier, whisperToggleSupplier; + private static ActionAccessor muteMicrophoneSupplier; + + private static boolean pttDown, whisperDown; + private static boolean pttToggleDown, whisperToggleDown; + private static boolean previousPttHold, previousWhisperHold; + private static Action previousPttAction, previousWhisperAction; + + public static void init() { + ActionEvents.REGISTER_ACTIONS.subscribeForever(SimpleVoiceChatCompat::registerActions); + ActionEvents.REGISTER_PROXIES.subscribeForever(SimpleVoiceChatCompat::registerProxies); + PlatformClientUtil.registerClientTickEnded(SimpleVoiceChatCompat::tick); + } + + private static void registerActions(ActionSpecRegistry registry) { + Component category = Component.translatable("key.categories.voicechat"); + pttHoldSupplier = register(registry, category, "ptt_hold", Component.translatable("controlify.action.voicechat.ptt_hold")); + pttToggleSupplier = register(registry, category, "ptt_toggle", Component.translatable("controlify.action.voicechat.ptt_toggle")); + whisperHoldSupplier = register(registry, category, "whisper_hold", Component.translatable("key.whisper") + .append(CommonComponents.SPACE).append(Component.translatable("controlify.compat.svc.hold"))); + whisperToggleSupplier = register(registry, category, "whisper_toggle", Component.translatable("key.whisper") + .append(CommonComponents.SPACE).append(Component.translatable("controlify.compat.svc.toggle"))); + muteMicrophoneSupplier = register(registry, category, "mute_microphone", Component.translatable("controlify.action.voicechat.mute_microphone")); + } + + private static ActionAccessor register(ActionSpecRegistry registry, Component category, String path, Component name) { + return registry.register(ActionSpec.builder() + .id(Identifier.fromNamespaceAndPath("voicechat", path)) + .name(name) + .category(category) + .layer(BuiltinActionLayers.IN_GAME) + .build()); + } + + private static void registerProxies(ActionProxyManager proxies) { + proxies.dontAutoProxyKeyMapping(KeyEvents.KEY_PTT); + proxies.dontAutoProxyKeyMapping(KeyEvents.KEY_WHISPER); + proxies.proxyKeyMapping(muteMicrophoneSupplier, KeyEvents.KEY_MUTE); + } + + private static void tick(Minecraft minecraft) { + ControlifyApi controlify = ControlifyApi.get(); + var controller = controlify.getCurrentController().orElse(null); + if (controller == null || pttHoldSupplier == null || pttToggleSupplier == null + || whisperHoldSupplier == null || whisperToggleSupplier == null) { + clearState(); + return; + } + + // Resolve on each tick: profile changes may replace action instances. + Action pttHold = pttHoldSupplier.onOrNull(controller); + Action pttToggle = pttToggleSupplier.onOrNull(controller); + Action whisperHold = whisperHoldSupplier.onOrNull(controller); + Action whisperToggle = whisperToggleSupplier.onOrNull(controller); + if (pttHold == null || pttToggle == null || whisperHold == null || whisperToggle == null) { + clearState(); + return; + } + if (!controlify.currentInputMode().isController()) { + // Discard ineligible toggle presses so they cannot replay when focus/input returns. + pttToggle.pulse().consumeAllPulses(); + whisperToggle.pulse().consumeAllPulses(); + clearState(); + return; + } + if (pttToggle != previousPttAction || whisperToggle != previousWhisperAction) { + clearState(); + previousPttAction = pttToggle; + previousWhisperAction = whisperToggle; + } + + // Pulses accumulate independently of frame/tick cadence; consume each pulse once. + while (pttToggle.pulse().consumePulse()) { + pttToggleDown = !pttToggleDown; + checkConnected(); + } + while (whisperToggle.pulse().consumePulse()) { + whisperToggleDown = !whisperToggleDown; + checkConnected(); + } + boolean pttHeld = pttHold.latch().isLatchActive(); + boolean whisperHeld = whisperHold.latch().isLatchActive(); + if ((pttHeld && !previousPttHold) || (whisperHeld && !previousWhisperHold)) { + checkConnected(); + } + previousPttHold = pttHeld; + previousWhisperHold = whisperHeld; + pttDown = pttHeld || pttToggleDown; + whisperDown = whisperHeld || whisperToggleDown; + + controller.dualsense().ifPresent(ds -> + ds.setMicLightMode(MicLightMode.fromBoolean(ClientManager.getPlayerStateManager().isMuted()))); + } + + private static void clearState() { + pttDown = whisperDown = false; + pttToggleDown = whisperToggleDown = false; + previousPttHold = previousWhisperHold = false; + previousPttAction = previousWhisperAction = null; + } + + public static boolean isPTTDown() { + return pttDown; + } + + public static boolean isWhisperDown() { + return whisperDown; + } + + private static void checkConnected() { + if (MinecraftUtil.getOverlay() == null && MinecraftUtil.getScreen() == null) { + ((KeyEventsAccessor) ClientManager.instance().getKeyEvents()).invokeCheckConnected(); + } + } + +} +//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/mixins/KeyEventsAccessor.java b/src/main/java/dev/isxander/controlify/compat/simplevoicechat/mixins/KeyEventsAccessor.java similarity index 85% rename from src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/mixins/KeyEventsAccessor.java rename to src/main/java/dev/isxander/controlify/compat/simplevoicechat/mixins/KeyEventsAccessor.java index ecc4a2f14..dd4c84535 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/mixins/KeyEventsAccessor.java +++ b/src/main/java/dev/isxander/controlify/compat/simplevoicechat/mixins/KeyEventsAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.simplevoicechat.mixins; +package dev.isxander.controlify.compat.simplevoicechat.mixins; //? if simple_voice_chat { diff --git a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/mixins/PTTKeyHandlerMixin.java b/src/main/java/dev/isxander/controlify/compat/simplevoicechat/mixins/PTTKeyHandlerMixin.java similarity index 88% rename from src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/mixins/PTTKeyHandlerMixin.java rename to src/main/java/dev/isxander/controlify/compat/simplevoicechat/mixins/PTTKeyHandlerMixin.java index 2ce75f8c3..b58557014 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/mixins/PTTKeyHandlerMixin.java +++ b/src/main/java/dev/isxander/controlify/compat/simplevoicechat/mixins/PTTKeyHandlerMixin.java @@ -4,13 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.simplevoicechat.mixins; +package dev.isxander.controlify.compat.simplevoicechat.mixins; //? if simple_voice_chat { import com.llamalad7.mixinextras.injector.ModifyExpressionValue; import de.maxhenkel.voicechat.voice.client.PTTKeyHandler; -import dev.isxander.controlify.compatibility.simplevoicechat.SimpleVoiceChatCompat; +import dev.isxander.controlify.compat.simplevoicechat.SimpleVoiceChatCompat; import org.objectweb.asm.Opcodes; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/SodiumMixinPlugin.java b/src/main/java/dev/isxander/controlify/compat/sodium/SodiumMixinPlugin.java similarity index 67% rename from src/main/java/dev/isxander/controlify/compatibility/sodium/SodiumMixinPlugin.java rename to src/main/java/dev/isxander/controlify/compat/sodium/SodiumMixinPlugin.java index 2899a76a3..638db4630 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/SodiumMixinPlugin.java +++ b/src/main/java/dev/isxander/controlify/compat/sodium/SodiumMixinPlugin.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.sodium; +package dev.isxander.controlify.compat.sodium; -import dev.isxander.controlify.compatibility.CompatMixinPlugin; +import dev.isxander.controlify.compat.CompatMixinPlugin; public class SodiumMixinPlugin extends CompatMixinPlugin { @Override diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/AbstractWidgetMixin.java b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/AbstractWidgetMixin.java similarity index 88% rename from src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/AbstractWidgetMixin.java rename to src/main/java/dev/isxander/controlify/compat/sodium/mixins/AbstractWidgetMixin.java index 91090a871..c2c634383 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/AbstractWidgetMixin.java +++ b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/AbstractWidgetMixin.java @@ -4,12 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.sodium.mixins; +package dev.isxander.controlify.compat.sodium.mixins; //? if sodium { import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.impl.Controlify; import net.minecraft.client.InputType; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; diff --git a/src/main/java/dev/isxander/controlify/compat/sodium/mixins/CycleControlElementMixin.java b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/CycleControlElementMixin.java new file mode 100644 index 000000000..6283724bd --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/CycleControlElementMixin.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.sodium.mixins; + +//? if sodium { + +import dev.isxander.controlify.impl.screenop.behaviours.element.ControlValueAccess; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +@Mixin(targets = "net.caffeinemc.mods.sodium.client.gui.options.control.CyclingControl$CyclingControlElement") +public abstract class CycleControlElementMixin implements ControlValueAccess { + @Shadow + protected abstract void cycleControl(boolean reverse); + + @Override + public void controlify$adjustValue(int direction) { + this.cycleControl(direction < 0); + } +} +//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/FlatButtonWidgetAccessor.java b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/FlatButtonWidgetAccessor.java similarity index 87% rename from src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/FlatButtonWidgetAccessor.java rename to src/main/java/dev/isxander/controlify/compat/sodium/mixins/FlatButtonWidgetAccessor.java index 6cd2513d5..1138c5760 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/FlatButtonWidgetAccessor.java +++ b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/FlatButtonWidgetAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.sodium.mixins; +package dev.isxander.controlify.compat.sodium.mixins; //? if sodium { diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/FlatButtonWidgetMixin.java b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/FlatButtonWidgetMixin.java similarity index 58% rename from src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/FlatButtonWidgetMixin.java rename to src/main/java/dev/isxander/controlify/compat/sodium/mixins/FlatButtonWidgetMixin.java index c9e592836..93b223d10 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/FlatButtonWidgetMixin.java +++ b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/FlatButtonWidgetMixin.java @@ -4,26 +4,24 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.sodium.mixins; +package dev.isxander.controlify.compat.sodium.mixins; //? if sodium { -import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.gui.ButtonGuideRenderer; +import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.impl.gui.ButtonGuideRenderer; import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap; +import net.caffeinemc.mods.sodium.client.gui.widgets.FlatButtonWidget; import net.minecraft.network.chat.Component; -import org.objectweb.asm.Opcodes; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; -import org.spongepowered.asm.mixin.injection.At; import java.util.Map; import java.util.Optional; -import net.caffeinemc.mods.sodium.client.gui.widgets.FlatButtonWidget; - @Mixin(FlatButtonWidget.class) public class FlatButtonWidgetMixin implements ButtonGuideRenderer { @Shadow @@ -32,23 +30,17 @@ public class FlatButtonWidgetMixin implements ButtonGuideRenderer renderData = null; - @Unique private final Map controllerMessages = new Object2ObjectArrayMap<>(2); + @Unique private final Map controllerMessages = new Object2ObjectArrayMap<>(2); - @ModifyExpressionValue( - method = "extractRenderState", - at = @At( - value = "FIELD", - target = "Lnet/caffeinemc/mods/sodium/client/gui/widgets/FlatButtonWidget;label:Lnet/minecraft/network/chat/Component;", - opcode = Opcodes.GETFIELD - ) - ) - private Component modifyRenderedLabel(Component actualLabel) { - return getControllerMessage(actualLabel); + @WrapMethod(method = "getRenderedLabel") + private Component modifyRenderedLabel(Operation original) { + return getControllerMessage(original.call()); } @Unique private Component getControllerMessage(Component actualLabel) { - if (!shouldRender()) + if (!shouldRender()) { return actualLabel; + } return getBind().map(bind -> controllerMessages .computeIfAbsent(bind, b -> renderData.getControllerMessage(b, actualLabel))) @@ -62,9 +54,11 @@ private Component modifyRenderedLabel(Component actualLabel) { && renderData.shouldRender((FlatButtonWidget) (Object) this); } - @Unique private Optional getBind() { - if (renderData == null) return Optional.empty(); - return renderData.getBind(); + @Unique private Optional getBind() { + if (renderData == null) { + return Optional.empty(); + } + return renderData.getAction(); } @Override diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/SliderControlElementMixin.java b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/SliderControlElementMixin.java similarity index 52% rename from src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/SliderControlElementMixin.java rename to src/main/java/dev/isxander/controlify/compat/sodium/mixins/SliderControlElementMixin.java index 2b8c05ed1..cd58a45c1 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/SliderControlElementMixin.java +++ b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/SliderControlElementMixin.java @@ -4,13 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.sodium.mixins; +package dev.isxander.controlify.compat.sodium.mixins; //? if sodium { -import dev.isxander.controlify.compatibility.sodium.screenop.SliderControlProcessor; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; +import dev.isxander.controlify.impl.screenop.behaviours.element.ControlValueAccess; import net.caffeinemc.mods.sodium.client.config.structure.IntegerOption; import net.caffeinemc.mods.sodium.client.gui.ColorTheme; import net.caffeinemc.mods.sodium.client.gui.options.control.AbstractOptionList; @@ -20,25 +18,20 @@ import org.spongepowered.asm.mixin.*; @Mixin(targets = "net.caffeinemc.mods.sodium.client.gui.options.control.SliderControl$SliderControlElement") -public abstract class SliderControlElementMixin extends ControlElement implements ComponentProcessorProvider { - @Shadow @Final private IntegerOption option; - - @Unique private final ComponentProcessor controlify$componentProcessor - = new SliderControlProcessor(this::controlify$incrementSlider); +public abstract class SliderControlElementMixin extends ControlElement implements ControlValueAccess { + @Shadow + @Final + private IntegerOption option; public SliderControlElementMixin(AbstractOptionList list, Dim2i dim, ColorTheme theme) { super(list, dim, theme); } @Override - public ComponentProcessor componentProcessor() { - return controlify$componentProcessor; - } - - @Unique private void controlify$incrementSlider(boolean reverse) { - var range = option.getSteppedValidator(); - option.modifyValue(Mth.clamp( - option.getValidatedValue() + (reverse ? -range.step() : range.step()), + public void controlify$adjustValue(int direction) { + var range = this.option.getSteppedValidator(); + this.option.modifyValue(Mth.clamp( + this.option.getValidatedValue() + (direction < 0 ? -range.step() : range.step()), range.min(), range.max() )); } diff --git a/src/main/java/dev/isxander/controlify/compat/sodium/mixins/TickBoxControlElementMixin.java b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/TickBoxControlElementMixin.java new file mode 100644 index 000000000..b1de199eb --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/TickBoxControlElementMixin.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.sodium.mixins; + +//? if sodium { + +import dev.isxander.controlify.impl.screenop.behaviours.element.ControlValueAccess; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; + +@Mixin(targets = "net.caffeinemc.mods.sodium.client.gui.options.control.TickBoxControl$TickBoxControlElement") +public abstract class TickBoxControlElementMixin implements ControlValueAccess { + @Shadow + protected abstract void toggleControl(); + + @Override + public void controlify$adjustValue(int direction) { + this.toggleControl(); + } +} +//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/VideoSettingsScreenMixin.java b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/VideoSettingsScreenMixin.java similarity index 71% rename from src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/VideoSettingsScreenMixin.java rename to src/main/java/dev/isxander/controlify/compat/sodium/mixins/VideoSettingsScreenMixin.java index cf87cfae3..201914cd3 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/VideoSettingsScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/compat/sodium/mixins/VideoSettingsScreenMixin.java @@ -4,14 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.sodium.mixins; +package dev.isxander.controlify.compat.sodium.mixins; //? if sodium { -import dev.isxander.controlify.compatibility.sodium.screenop.SodiumGuiScreenProcessor; -import dev.isxander.controlify.compatibility.sodium.screenop.SodiumScreenOperations; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; +import dev.isxander.controlify.compat.sodium.screenop.SodiumScreenOperations; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; import net.caffeinemc.mods.sodium.client.config.ConfigManager; import net.caffeinemc.mods.sodium.client.config.structure.Page; import net.caffeinemc.mods.sodium.client.gui.VideoSettingsScreen; @@ -27,21 +25,23 @@ import java.util.List; @Mixin(VideoSettingsScreen.class) -public abstract class VideoSettingsScreenMixin extends Screen implements ScreenProcessorProvider, SodiumScreenOperations { +public abstract class VideoSettingsScreenMixin extends Screen implements SodiumScreenOperations { //? if >=26.2 { - @Shadow private net.caffeinemc.mods.sodium.client.gui.widgets.KeyBoundButtonWidget applyButton; - @Shadow private net.caffeinemc.mods.sodium.client.gui.widgets.KeyBoundButtonWidget closeButton; - @Shadow private net.caffeinemc.mods.sodium.client.gui.widgets.KeyBoundButtonWidget undoButton; + @Shadow + private net.caffeinemc.mods.sodium.client.gui.widgets.KeyBoundButtonWidget applyButton; + @Shadow + private net.caffeinemc.mods.sodium.client.gui.widgets.KeyBoundButtonWidget closeButton; + @Shadow + private net.caffeinemc.mods.sodium.client.gui.widgets.KeyBoundButtonWidget undoButton; //?} else { /*@Shadow private FlatButtonWidget applyButton; @Shadow private FlatButtonWidget closeButton; @Shadow private FlatButtonWidget undoButton; *///?} - @Shadow private OptionListWidget optionList; + @Shadow + private OptionListWidget optionList; @Unique private Page controlify$currentPage = null; - @Unique private final SodiumGuiScreenProcessor controlify$screenProcessor - = new SodiumGuiScreenProcessor((VideoSettingsScreen) (Object) this, this); protected VideoSettingsScreenMixin(Component title) { super(title); @@ -52,7 +52,7 @@ protected VideoSettingsScreenMixin(Component title) { if (optionList != null && !optionList.getControls().isEmpty()) { this.setInitialFocus(optionList.getControls().get(0)); } - controlify$screenProcessor.onRebuildGUI(); + ScreenContextImpl.get(this).onWidgetRebuild(); } @Inject(method = "onSectionFocused", at = @At("HEAD")) @@ -60,11 +60,6 @@ protected VideoSettingsScreenMixin(Component title) { controlify$currentPage = page; } - @Override - public ScreenProcessor screenProcessor() { - return controlify$screenProcessor; - } - @Override public void controlify$nextPage() { List pages = controlify$getAllPages(); @@ -89,7 +84,8 @@ public ScreenProcessor screenProcessor() { .toList(); } - @Shadow public abstract void jumpToPage(Page page); + @Shadow + public abstract void jumpToPage(Page page); @Override public FlatButtonWidget controlify$getApplyButton() { diff --git a/src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenBehaviour.java b/src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenBehaviour.java new file mode 100644 index 000000000..df0a04015 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenBehaviour.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.sodium.screenop; + +//? if sodium { + +import dev.isxander.controlify.api.action.BuiltinActions; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.compat.sodium.mixins.FlatButtonWidgetAccessor; + +public final class SodiumScreenBehaviour implements ScreenBehaviour { + private final SodiumScreenOperations operations; + + private final ScreenContext context; + + public SodiumScreenBehaviour(ScreenContext context, SodiumScreenOperations operations) { + this.context = context; + this.operations = operations; + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (BuiltinActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + ((FlatButtonWidgetAccessor) operations.controlify$getApplyButton()).invokeDoAction(); + } + if (BuiltinActions.GUI_ABSTRACT_ACTION_2.on(controller).pulse().consumeAllPulses()) { + ((FlatButtonWidgetAccessor) operations.controlify$getUndoButton()).invokeDoAction(); + } + + next.run(); + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + while (BuiltinActions.GUI_NEXT_TAB.on(controller).pulse().consumePulse()) { + operations.controlify$nextPage(); + } + + while (BuiltinActions.GUI_PREV_TAB.on(controller).pulse().consumePulse()) { + operations.controlify$prevPage(); + } + + next.run(); + } + + @Override + public void onWidgetRebuild(Runnable next) { + context.buttonGuide().addGuideToButton( + operations.controlify$getApplyButton(), + BuiltinActions.GUI_ABSTRACT_ACTION_1, + ButtonGuidePredicate.always() + ); + context.buttonGuide().addGuideToButton( + operations.controlify$getUndoButton(), + BuiltinActions.GUI_ABSTRACT_ACTION_2, + ButtonGuidePredicate.always() + ); + + context.buttonGuide().addGuideToButton( + operations.controlify$getCloseButton(), + BuiltinActions.GUI_BACK, + ButtonGuidePredicate.always() + ); + + next.run(); + } +} +//?} diff --git a/src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenBehaviours.java b/src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenBehaviours.java new file mode 100644 index 000000000..729851703 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenBehaviours.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.sodium.screenop; + +//? if sodium { + +import dev.isxander.controlify.api.screenop.ElementBehaviourFactory; +import dev.isxander.controlify.api.screenop.ElementBehaviourRegistry; +import dev.isxander.controlify.api.screenop.ElementBehaviours; +import dev.isxander.controlify.api.screenop.ScreenopEvents; +import dev.isxander.controlify.impl.screenop.behaviours.element.ControlValueAccess; +import net.caffeinemc.mods.sodium.client.gui.VideoSettingsScreen; +import net.minecraft.client.gui.components.events.GuiEventListener; + +public final class SodiumScreenBehaviours { + private SodiumScreenBehaviours() { + } + + public static void initialize() { + ScreenopEvents.REGISTER_SCREEN_BEHAVIOURS.subscribeForever(registry -> { + registry.register(VideoSettingsScreen.class, context -> new SodiumScreenBehaviour(context, (SodiumScreenOperations) context.screen())); + //? if reeses_sodium_options { + /*if (dev.isxander.controlify.impl.platform.main.PlatformMainUtil.isModLoaded("reeses-sodium-options")) { + registry.register(me.flashyreese.mods.reeses_sodium_options.client.gui.SodiumVideoOptionsScreen.class, context -> new SodiumScreenBehaviour(context, (SodiumScreenOperations) context.screen())); + } + *///?} + }); + ScreenopEvents.REGISTER_ELEMENT_BEHAVIOURS.subscribeForever(event -> { + registerControl(event.registry(), "CyclingControl$CyclingControlElement", ElementBehaviours.cycle( + (widget, direction) -> ((ControlValueAccess) widget).controlify$adjustValue(direction) + )); + registerControl(event.registry(), "SliderControl$SliderControlElement", ElementBehaviours.secondarySlider( + (widget, direction) -> ((ControlValueAccess) widget).controlify$adjustValue(direction) + )); + registerControl(event.registry(), "TickBoxControl$TickBoxControlElement", ElementBehaviours.cycle( + (widget, direction) -> ((ControlValueAccess) widget).controlify$adjustValue(direction) + )); + }); + } + + private static void registerControl(ElementBehaviourRegistry registry, String name, ElementBehaviourFactory factory) { + try { + var type = Class.forName("net.caffeinemc.mods.sodium.client.gui.options.control." + name) + .asSubclass(GuiEventListener.class); + registry.register(type, factory); + } catch (ClassNotFoundException exception) { + throw new IllegalStateException("Sodium control class is unavailable: " + name, exception); + } + } +} +//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SodiumScreenOperations.java b/src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenOperations.java similarity index 88% rename from src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SodiumScreenOperations.java rename to src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenOperations.java index be61f804f..523b643c8 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SodiumScreenOperations.java +++ b/src/main/java/dev/isxander/controlify/compat/sodium/screenop/SodiumScreenOperations.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.sodium.screenop; +package dev.isxander.controlify.compat.sodium.screenop; //? if sodium { diff --git a/src/main/java/dev/isxander/controlify/compat/sodium/screenop/package-info.java b/src/main/java/dev/isxander/controlify/compat/sodium/screenop/package-info.java new file mode 100644 index 000000000..b06b5717e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/sodium/screenop/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.compat.sodium.screenop; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/YACLMixinPlugin.java b/src/main/java/dev/isxander/controlify/compat/yacl/YACLMixinPlugin.java similarity index 69% rename from src/main/java/dev/isxander/controlify/compatibility/yacl/YACLMixinPlugin.java rename to src/main/java/dev/isxander/controlify/compat/yacl/YACLMixinPlugin.java index ab2ad90cd..bca2ef200 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/YACLMixinPlugin.java +++ b/src/main/java/dev/isxander/controlify/compat/yacl/YACLMixinPlugin.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.yacl; +package dev.isxander.controlify.compat.yacl; -import dev.isxander.controlify.compatibility.CompatMixinPlugin; +import dev.isxander.controlify.compat.CompatMixinPlugin; public class YACLMixinPlugin extends CompatMixinPlugin { @Override diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/ControllerWidgetAccessor.java b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/ControllerWidgetAccessor.java similarity index 88% rename from src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/ControllerWidgetAccessor.java rename to src/main/java/dev/isxander/controlify/compat/yacl/mixins/ControllerWidgetAccessor.java index b71b7b205..bf85d92a3 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/ControllerWidgetAccessor.java +++ b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/ControllerWidgetAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.yacl.mixins; +package dev.isxander.controlify.compat.yacl.mixins; import dev.isxander.yacl3.api.Controller; import dev.isxander.yacl3.gui.controllers.ControllerWidget; diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/GroupSeparatorEntryAccessor.java b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/GroupSeparatorEntryAccessor.java similarity index 88% rename from src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/GroupSeparatorEntryAccessor.java rename to src/main/java/dev/isxander/controlify/compat/yacl/mixins/GroupSeparatorEntryAccessor.java index 5982f854d..abf5edd5a 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/GroupSeparatorEntryAccessor.java +++ b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/GroupSeparatorEntryAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.yacl.mixins; +package dev.isxander.controlify.compat.yacl.mixins; import dev.isxander.yacl3.gui.OptionListWidget; import org.spongepowered.asm.mixin.Mixin; diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/StringControllerElementAccessor.java b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/StringControllerElementAccessor.java similarity index 87% rename from src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/StringControllerElementAccessor.java rename to src/main/java/dev/isxander/controlify/compat/yacl/mixins/StringControllerElementAccessor.java index e47c13b88..ef7801b4f 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/StringControllerElementAccessor.java +++ b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/StringControllerElementAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.yacl.mixins; +package dev.isxander.controlify.compat.yacl.mixins; import dev.isxander.yacl3.gui.controllers.string.StringControllerElement; import org.spongepowered.asm.mixin.Mixin; diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenCategoryTabAccessor.java b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/YACLScreenCategoryTabAccessor.java similarity index 91% rename from src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenCategoryTabAccessor.java rename to src/main/java/dev/isxander/controlify/compat/yacl/mixins/YACLScreenCategoryTabAccessor.java index fae748607..2fec15882 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenCategoryTabAccessor.java +++ b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/YACLScreenCategoryTabAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.yacl.mixins; +package dev.isxander.controlify.compat.yacl.mixins; import dev.isxander.yacl3.gui.OptionListWidget; import dev.isxander.yacl3.gui.WidgetAndType; diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenCategoryTabMixin.java b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/YACLScreenCategoryTabMixin.java similarity index 65% rename from src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenCategoryTabMixin.java rename to src/main/java/dev/isxander/controlify/compat/yacl/mixins/YACLScreenCategoryTabMixin.java index 9fb2756f1..18be99e94 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenCategoryTabMixin.java +++ b/src/main/java/dev/isxander/controlify/compat/yacl/mixins/YACLScreenCategoryTabMixin.java @@ -4,11 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.yacl.mixins; +package dev.isxander.controlify.compat.yacl.mixins; -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.impl.action.ControlifyActions; import dev.isxander.yacl3.gui.YACLScreen; import net.minecraft.client.gui.components.Button; import org.spongepowered.asm.mixin.Final; @@ -20,11 +19,12 @@ @Mixin(YACLScreen.CategoryTab.class) public class YACLScreenCategoryTabMixin { - @Shadow @Final + @Shadow + @Final public Button saveFinishedButton; @Inject(method = "", at = @At("RETURN"), require = 0) private void onConstructCategory(CallbackInfo ci) { - ButtonGuideApi.addGuideToButton(saveFinishedButton, ControlifyBindings.GUI_ABSTRACT_ACTION_1, ButtonGuidePredicate.always()); + dev.isxander.controlify.impl.gui.ButtonGuideApiImpl.INSTANCE.addGuideToButton(saveFinishedButton, ControlifyActions.GUI_ABSTRACT_ACTION_1, ButtonGuidePredicate.always()); } } diff --git a/src/main/java/dev/isxander/controlify/compat/yacl/screenop/OptionListWidgetBehaviour.java b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/OptionListWidgetBehaviour.java new file mode 100644 index 000000000..0d65c1ba5 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/OptionListWidgetBehaviour.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.yacl.screenop; + +import dev.isxander.controlify.api.action.BuiltinActions; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.compat.yacl.mixins.GroupSeparatorEntryAccessor; +import dev.isxander.yacl3.gui.OptionListWidget; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +public class OptionListWidgetBehaviour implements ElementBehaviour { + private final OptionListWidget optionListWidget; + + public OptionListWidgetBehaviour(OptionListWidget optionListWidget) { + this.optionListWidget = optionListWidget; + } + + /** + * Allows navigating between groups using secondary navigation up/down. + */ + @Override + public void handleNavigation(ScreenContext screen, Controller controller, Runnable next) { + boolean handled = false; + while (BuiltinActions.GUI_SECONDARY_NAVI_UP.on(controller).pulse().consumePulse()) { + handled |= moveGroup(this.optionListWidget, -1); + } + while (BuiltinActions.GUI_SECONDARY_NAVI_DOWN.on(controller).pulse().consumePulse()) { + handled |= moveGroup(this.optionListWidget, 1); + } + if (!handled) { + next.run(); + } + } + + private static boolean moveGroup(OptionListWidget optionList, int direction) { + OptionListWidget.GroupSeparatorEntry nextGroup = findNextGroup(optionList, direction); + if (nextGroup != null) { + if (nextGroup.isExpanded()) { + var childEntries = ((GroupSeparatorEntryAccessor) nextGroup).getChildEntries(); + if (!childEntries.isEmpty()) { + var childEntry = childEntries.get(0); + var buttonWithinChild = childEntry.children().get(0); + optionList.setFocused(childEntries.get(0)); + childEntry.setFocused(buttonWithinChild); + } else { + optionList.setFocused(nextGroup); + } + } else { + optionList.setFocused(nextGroup); + } + return true; + } + return false; + } + + private static @Nullable OptionListWidget.GroupSeparatorEntry findNextGroup(OptionListWidget optionList, int direction) { + List entries = optionList.children(); + OptionListWidget.Entry focusedEntry = optionList.getFocused(); + int currentIndex = entries.indexOf(focusedEntry); + if (direction < 0) { + currentIndex -= 2; + } + if (currentIndex < 0) { + currentIndex = 0; + } + + for (int i = currentIndex; i >= 0 && i < entries.size(); i += direction) { + OptionListWidget.Entry entry = entries.get(i); + if (entry instanceof OptionListWidget.GroupSeparatorEntry groupEntry) { + return groupEntry; + } + } + + return null; + } +} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/StringControllerElementComponentProcessor.java b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/StringControllerElementBehaviour.java similarity index 56% rename from src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/StringControllerElementComponentProcessor.java rename to src/main/java/dev/isxander/controlify/compat/yacl/screenop/StringControllerElementBehaviour.java index ac0f15fbd..8dcfcd0ce 100644 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/StringControllerElementComponentProcessor.java +++ b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/StringControllerElementBehaviour.java @@ -4,39 +4,39 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.compatibility.yacl.screenop; +package dev.isxander.controlify.compat.yacl.screenop; import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.compatibility.yacl.mixins.StringControllerElementAccessor; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.keyboard.ComponentKeyboardBehaviour; -import dev.isxander.controlify.screenop.keyboard.InputTarget; -import dev.isxander.controlify.screenop.keyboard.KeyboardLayouts; -import dev.isxander.controlify.screenop.keyboard.KeyboardOverlayScreen; +import dev.isxander.controlify.api.gui.keyboard.InputTarget; +import dev.isxander.controlify.api.gui.keyboard.KeyboardLayouts; +import dev.isxander.controlify.api.gui.keyboard.KeyboardPositioner; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.api.screenop.ElementKeyboardBehaviour; +import dev.isxander.controlify.compat.yacl.mixins.StringControllerElementAccessor; import dev.isxander.yacl3.gui.controllers.string.StringControllerElement; import net.minecraft.client.input.CharacterEvent; import net.minecraft.client.input.KeyEvent; -public class StringControllerElementComponentProcessor implements ComponentProcessor { +import java.util.Optional; + +public class StringControllerElementBehaviour implements ElementBehaviour { private final StringControllerElement element; - public StringControllerElementComponentProcessor(StringControllerElement element) { + public StringControllerElementBehaviour(StringControllerElement element) { this.element = element; } @Override - public ComponentKeyboardBehaviour getKeyboardBehaviour(ScreenProcessor screen, ControllerEntity controller) { - return new ComponentKeyboardBehaviour.Handled( - KeyboardLayouts.simple(), + public Optional keyboardBehaviour(ScreenContext screen) { + return Optional.of(new ElementKeyboardBehaviour.Handled( + KeyboardLayouts.SIMPLE, new StringInputTarget(), - KeyboardOverlayScreen.aboveOrBelowWidgetPositioner( - (int) (screen.screen.width * 0.8f), (int) (screen.screen.height * 0.4f), + KeyboardPositioner.aboveOrBelowWidgetPositioner( + (int) (screen.screen().width * 0.8f), (int) (screen.screen().height * 0.4f), 1, element::getRectangle ) - ); + )); } private class StringInputTarget implements InputTarget { @@ -68,7 +68,7 @@ public boolean supportsCursorMovement() { @Override public boolean moveCursor(int amount) { int keycode = amount > 0 ? InputConstants.KEY_RIGHT : InputConstants.KEY_LEFT; - for (int i = 0; i < amount; i++) { + for (int i = 0; i < Math.abs(amount); i++) { element.keyPressed(new KeyEvent(keycode, 0, 0)); } return true; diff --git a/src/main/java/dev/isxander/controlify/compat/yacl/screenop/YACLScreenBehaviour.java b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/YACLScreenBehaviour.java new file mode 100644 index 000000000..cb9dc0a6f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/YACLScreenBehaviour.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.yacl.screenop; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.isxander.controlify.api.action.BuiltinActions; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.compat.yacl.mixins.YACLScreenCategoryTabAccessor; +import dev.isxander.yacl3.gui.OptionListWidget; +import dev.isxander.yacl3.gui.WidgetAndType; +import dev.isxander.yacl3.gui.YACLScreen; +import net.minecraft.client.input.KeyEvent; + +public final class YACLScreenBehaviour implements ScreenBehaviour { + private final ScreenContext context; + private final YACLScreen screen; + + public YACLScreenBehaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (BuiltinActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + if (screen.tabManager.getCurrentTab() instanceof YACLScreen.CategoryTab categoryTab) { + ((YACLScreenCategoryTabAccessor) categoryTab).getSaveFinishedButton().onPress(new KeyEvent(InputConstants.KEY_RETURN, 0, 0)); + } + context.playClickSound(); + } + + next.run(); + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + var previous = screen.tabManager.getCurrentTab(); + next.run(); + if (dev.isxander.controlify.impl.utils.MinecraftUtil.getScreen() != screen + || screen.tabManager.getCurrentTab() == previous) { + return; + } + if (screen.tabManager.getCurrentTab() instanceof YACLScreen.CategoryTab categoryTab) { + WidgetAndType optionListHolder = ((YACLScreenCategoryTabAccessor) categoryTab).getOptionList(); + OptionListWidget optionList = optionListHolder.getType(); + optionList.setScrollAmount(0); + + screen.setFocused(optionListHolder.getWidget()); + + if (optionList.children().isEmpty()) { + return; + } + + for (OptionListWidget.Entry entry : optionList.children()) { + entry.setFocused(false); + entry.setFocused(null); + } + + OptionListWidget.Entry firstEntry = optionList.children().get(0); + optionList.setFocused(firstEntry); + + if (!firstEntry.children().isEmpty()) { + firstEntry.setFocused(firstEntry.children().get(0)); + } + } + } +} diff --git a/src/main/java/dev/isxander/controlify/compat/yacl/screenop/YACLScreenBehaviours.java b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/YACLScreenBehaviours.java new file mode 100644 index 000000000..a1bc9d104 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/YACLScreenBehaviours.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.compat.yacl.screenop; + +import dev.isxander.controlify.api.screenop.ElementBehaviours; +import dev.isxander.controlify.api.screenop.ScreenopEvents; +import dev.isxander.controlify.compat.yacl.mixins.ControllerWidgetAccessor; +import dev.isxander.yacl3.gui.OptionListWidget; +import dev.isxander.yacl3.gui.YACLScreen; +import dev.isxander.yacl3.gui.controllers.cycling.CyclingControllerElement; +import dev.isxander.yacl3.gui.controllers.slider.SliderControllerElement; +import dev.isxander.yacl3.gui.controllers.string.StringControllerElement; + +public final class YACLScreenBehaviours { + private YACLScreenBehaviours() { + } + + public static void initialize() { + ScreenopEvents.REGISTER_SCREEN_BEHAVIOURS.subscribeForever(registry -> + registry.register(YACLScreen.class, YACLScreenBehaviour::new) + ); + ScreenopEvents.REGISTER_ELEMENT_BEHAVIOURS.subscribeForever(event -> { + var registry = event.registry(); + registry.register(OptionListWidget.class, OptionListWidgetBehaviour::new); + registry.register(CyclingControllerElement.class, element -> ElementBehaviours.conditional( + () -> ((ControllerWidgetAccessor) element).getControl().option().available(), + ElementBehaviours.cycle(CyclingControllerElement::cycleValue).create(element) + )); + registry.register(SliderControllerElement.class, element -> ElementBehaviours.conditional( + () -> ((ControllerWidgetAccessor) element).getControl().option().available(), + ElementBehaviours.secondarySlider(SliderControllerElement::incrementValue).create(element) + )); + registry.register(StringControllerElement.class, StringControllerElementBehaviour::new); + }); + } +} diff --git a/src/main/java/dev/isxander/controlify/compat/yacl/screenop/package-info.java b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/package-info.java new file mode 100644 index 000000000..c35cb5c30 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/compat/yacl/screenop/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.compat.yacl.screenop; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/compatibility/iris/mixins/BaseOptionElementWidgetMixin.java b/src/main/java/dev/isxander/controlify/compatibility/iris/mixins/BaseOptionElementWidgetMixin.java deleted file mode 100644 index 50d7ca7f6..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/iris/mixins/BaseOptionElementWidgetMixin.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.iris.mixins; - -//? if iris { - -import dev.isxander.controlify.compatibility.iris.screenop.BaseOptionElementComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import net.irisshaders.iris.gui.NavigationController; -import net.irisshaders.iris.gui.element.widget.BaseOptionElementWidget; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(BaseOptionElementWidget.class) -public abstract class BaseOptionElementWidgetMixin implements ComponentProcessorProvider { - @Shadow public abstract boolean applyPreviousValue(); - @Shadow public abstract boolean applyNextValue(); - - @Shadow protected NavigationController navigation; - @Unique private final BaseOptionElementComponentProcessor processor - = new BaseOptionElementComponentProcessor(this::cycle); - - @Override - public ComponentProcessor componentProcessor() { - return processor; - } - - @Unique private void cycle(boolean reverse) { - boolean needsUpdate = reverse ? applyPreviousValue() : applyNextValue(); - if (needsUpdate) { - navigation.refresh(); - } - } - -} -//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/iris/screenop/BaseOptionElementComponentProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/iris/screenop/BaseOptionElementComponentProcessor.java deleted file mode 100644 index 138d6ee85..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/iris/screenop/BaseOptionElementComponentProcessor.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.iris.screenop; - -//? if iris { - -import dev.isxander.controlify.screenop.compat.AbstractSliderComponentProcessor; - -import java.util.function.Consumer; - -public class BaseOptionElementComponentProcessor extends AbstractSliderComponentProcessor { - private final Consumer cycleMethod; - - public BaseOptionElementComponentProcessor(Consumer cycleMethod) { - this.cycleMethod = cycleMethod; - } - - @Override - protected void incrementSlider(boolean reverse) { - this.cycleMethod.accept(reverse); - } -} -//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SimpleVoiceChatCompat.java b/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SimpleVoiceChatCompat.java deleted file mode 100644 index 12d0f5de2..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SimpleVoiceChatCompat.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.simplevoicechat; - -//? if simple_voice_chat { - -import de.maxhenkel.voicechat.voice.client.ClientManager; -import de.maxhenkel.voicechat.voice.client.KeyEvents; -import dev.isxander.controlify.api.event.ControlifyEvents; -import dev.isxander.controlify.api.bind.ControlifyBindApi; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.compatibility.simplevoicechat.mixins.KeyEventsAccessor; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.utils.MinecraftUtil; -import net.minecraft.network.chat.CommonComponents; -import net.minecraft.network.chat.Component; - -public class SimpleVoiceChatCompat { - private static InputBindingSupplier pttHoldSupplier, pttToggleSupplier; - private static InputBindingSupplier whisperHoldSupplier, whisperToggleSupplier; - - private static boolean pttDown, whisperDown; - - public static void init() { - Component category = Component.translatable("key.categories.voicechat"); - pttHoldSupplier = ControlifyBindApi.get().registerBinding(builder -> builder - .id("voicechat", "ptt_hold") - .category(category) - .addKeyCorrelation(KeyEvents.KEY_PTT)); - pttToggleSupplier = ControlifyBindApi.get().registerBinding(builder -> builder - .id("voicechat", "ptt_toggle") - .category(category) - .addKeyCorrelation(KeyEvents.KEY_PTT)); - whisperHoldSupplier = ControlifyBindApi.get().registerBinding(builder -> builder - .name(Component.translatable("key.whisper").append(CommonComponents.SPACE).append(Component.translatable("controlify.compat.svc.hold"))) - .id("voicechat", "whisper_hold") - .category(category) - .addKeyCorrelation(KeyEvents.KEY_WHISPER)); - whisperToggleSupplier = ControlifyBindApi.get().registerBinding(builder -> builder - .name(Component.translatable("key.whisper").append(CommonComponents.SPACE).append(Component.translatable("controlify.compat.svc.toggle"))) - .id("voicechat", "whisper_toggle") - .category(category) - .addKeyCorrelation(KeyEvents.KEY_WHISPER)); - ControlifyBindApi.get().registerBinding(builder -> builder - .id("voicechat", "mute_microphone") - .category(category) - .keyEmulation(KeyEvents.KEY_MUTE)); - - ControlifyEvents.ACTIVE_CONTROLLER_TICKED.register(event -> { - ControllerEntity controller = event.controller(); - - var pttHold = pttHoldSupplier.on(controller); - var pttToggle = pttToggleSupplier.on(controller); - var whisperHold = whisperHoldSupplier.on(controller); - var whisperToggle = whisperToggleSupplier.on(controller); - - if (pttToggle.justPressed()) { - pttDown = !pttDown; - checkConnected(); - } - if (whisperToggle.justPressed()) { - whisperDown = !whisperDown; - checkConnected(); - } - - if (pttHold.justPressed() || whisperHold.justPressed()) { - checkConnected(); - } - if (pttHold.digitalNow()) { - pttDown = true; - } else if (pttHold.justReleased()) { - pttDown = false; - } - if (whisperHold.digitalNow()) { - whisperDown = true; - } else if (whisperHold.justReleased()) { - whisperDown = false; - } - - controller.dualSense().ifPresent(ds -> { - ds.setMuteLight(ClientManager.getPlayerStateManager().isMuted()); - }); - }); - } - - public static boolean isPTTDown() { - return pttDown; - } - - public static boolean isWhisperDown() { - return whisperDown; - } - - private static void checkConnected() { - if (MinecraftUtil.getOverlay() == null && MinecraftUtil.getScreen() == null) { - ((KeyEventsAccessor) ClientManager.instance().getKeyEvents()).invokeCheckConnected(); - } - } - -} -//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/CycleControlElementMixin.java b/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/CycleControlElementMixin.java deleted file mode 100644 index 8c3aa78ba..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/CycleControlElementMixin.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.sodium.mixins; - -//? if sodium { - -import dev.isxander.controlify.compatibility.sodium.screenop.CycleControlProcessor; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(targets = "net.caffeinemc.mods.sodium.client.gui.options.control.CyclingControl$CyclingControlElement") -public abstract class CycleControlElementMixin implements ComponentProcessorProvider { - @Shadow protected abstract void cycleControl(boolean reverse); - - @Unique private final ComponentProcessor controlify$componentProcessor - = new CycleControlProcessor(this::cycleControl); - - @Override - public ComponentProcessor componentProcessor() { - return controlify$componentProcessor; - } -} -//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/TickBoxControlElementMixin.java b/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/TickBoxControlElementMixin.java deleted file mode 100644 index 82efa42b0..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/mixins/TickBoxControlElementMixin.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.sodium.mixins; - -//? if sodium { - -import dev.isxander.controlify.compatibility.sodium.screenop.TickBoxControlProcessor; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(targets = "net.caffeinemc.mods.sodium.client.gui.options.control.TickBoxControl$TickBoxControlElement") -public abstract class TickBoxControlElementMixin implements ComponentProcessorProvider { - @Shadow - protected abstract void toggleControl(); - - @Unique private final ComponentProcessor controlify$componentProcessor - = new TickBoxControlProcessor(this::toggleControl); - - @Override - public ComponentProcessor componentProcessor() { - return controlify$componentProcessor; - } -} -//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/CycleControlProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/CycleControlProcessor.java deleted file mode 100644 index d86593f8a..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/CycleControlProcessor.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.sodium.screenop; - -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenProcessor; - -import java.util.function.Consumer; - -public class CycleControlProcessor implements ComponentProcessor { - private final Consumer cycleMethod; - - public CycleControlProcessor(Consumer cycleMethod) { - this.cycleMethod = cycleMethod; - } - - @Override - public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) { - if (ControlifyBindings.GUI_SECONDARY_NAVI_RIGHT.on(controller).justPressed() - || ControlifyBindings.GUI_PRESS.on(controller).justPressed() - ) { - cycleMethod.accept(false); - return true; - } - if (ControlifyBindings.GUI_SECONDARY_NAVI_LEFT.on(controller).justPressed()) { - cycleMethod.accept(true); - return true; - } - - return false; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SliderControlProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SliderControlProcessor.java deleted file mode 100644 index 266ba02dc..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SliderControlProcessor.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.sodium.screenop; - -import dev.isxander.controlify.screenop.compat.AbstractSliderComponentProcessor; - -import java.util.function.Consumer; - -public class SliderControlProcessor extends AbstractSliderComponentProcessor { - private final Consumer cycleMethod; - - public SliderControlProcessor(Consumer cycleMethod) { - this.cycleMethod = cycleMethod; - } - - @Override - protected void incrementSlider(boolean reverse) { - cycleMethod.accept(reverse); - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SodiumGuiScreenProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SodiumGuiScreenProcessor.java deleted file mode 100644 index 12667a3fb..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/SodiumGuiScreenProcessor.java +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.sodium.screenop; - -//? if sodium { - -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.compatibility.sodium.mixins.FlatButtonWidgetAccessor; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ScreenProcessor; -import net.minecraft.client.gui.screens.Screen; - -public class SodiumGuiScreenProcessor extends ScreenProcessor { - private final SodiumScreenOperations operations; - - public SodiumGuiScreenProcessor(Screen screen, SodiumScreenOperations operations) { - super(screen); - this.operations = operations; - } - - @Override - protected void handleComponentNavigation(ControllerEntity controller) { - super.handleComponentNavigation(controller); - } - - @Override - protected void handleButtons(ControllerEntity controller) { - if (ControlifyBindings.GUI_ABSTRACT_ACTION_1.on(controller).justPressed()) { - ((FlatButtonWidgetAccessor) operations.controlify$getApplyButton()).invokeDoAction(); - } - if (ControlifyBindings.GUI_ABSTRACT_ACTION_2.on(controller).justPressed()) { - ((FlatButtonWidgetAccessor) operations.controlify$getUndoButton()).invokeDoAction(); - } - - if (ControlifyBindings.GUI_NEXT_TAB.on(controller).justPressed()) { - operations.controlify$nextPage(); - } - - if (ControlifyBindings.GUI_PREV_TAB.on(controller).justPressed()) { - operations.controlify$prevPage(); - } - - super.handleButtons(controller); - } - - public void onRebuildGUI() { - ButtonGuideApi.addGuideToButton( - operations.controlify$getApplyButton(), - ControlifyBindings.GUI_ABSTRACT_ACTION_1, - ButtonGuidePredicate.always() - ); - ButtonGuideApi.addGuideToButton( - operations.controlify$getUndoButton(), - ControlifyBindings.GUI_ABSTRACT_ACTION_2, - ButtonGuidePredicate.always() - ); - - ButtonGuideApi.addGuideToButton( - operations.controlify$getCloseButton(), - ControlifyBindings.GUI_BACK, - ButtonGuidePredicate.always() - ); - - super.onWidgetRebuild(); - } -} -//?} diff --git a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/TickBoxControlProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/TickBoxControlProcessor.java deleted file mode 100644 index 376066774..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/sodium/screenop/TickBoxControlProcessor.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.sodium.screenop; - -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenProcessor; - -public class TickBoxControlProcessor implements ComponentProcessor { - private final Runnable toggleMethod; - - public TickBoxControlProcessor(Runnable toggleMethod) { - this.toggleMethod = toggleMethod; - } - - @Override - public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) { - if (ControlifyBindings.GUI_PRESS.on(controller).guiPressed().get()) { - toggleMethod.run(); - return true; - } - if (ControlifyBindings.GUI_SECONDARY_NAVI_RIGHT.on(controller).justPressed() || ControlifyBindings.GUI_SECONDARY_NAVI_LEFT.on(controller).justPressed()) { - toggleMethod.run(); - return true; - } - - return false; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/CyclingControllerElementMixin.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/CyclingControllerElementMixin.java deleted file mode 100644 index 867ffaa98..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/CyclingControllerElementMixin.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.mixins; - -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.compatibility.yacl.screenop.CyclingControllerElementComponentProcessor; -import dev.isxander.yacl3.gui.controllers.cycling.CyclingControllerElement; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(CyclingControllerElement.class) -public class CyclingControllerElementMixin implements ComponentProcessorProvider { - @Unique private final CyclingControllerElementComponentProcessor controlify$processor - = new CyclingControllerElementComponentProcessor((CyclingControllerElement) (Object) this); - - @Override - public ComponentProcessor componentProcessor() { - return controlify$processor; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/OptionListWidgetMixin.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/OptionListWidgetMixin.java deleted file mode 100644 index 023fb1593..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/OptionListWidgetMixin.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.mixins; - -import dev.isxander.controlify.compatibility.yacl.screenop.OptionListWidgetComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.yacl3.gui.OptionListWidget; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(OptionListWidget.class) -public class OptionListWidgetMixin implements ComponentProcessorProvider { - @Unique private final OptionListWidgetComponentProcessor controlify$processor = - new OptionListWidgetComponentProcessor((OptionListWidget) (Object) this); - - @Override - public ComponentProcessor componentProcessor() { - return this.controlify$processor; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/SliderControllerElementMixin.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/SliderControllerElementMixin.java deleted file mode 100644 index bdc484c14..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/SliderControllerElementMixin.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.mixins; - -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.compatibility.yacl.screenop.SliderControllerElementComponentProcessor; -import dev.isxander.yacl3.gui.controllers.slider.SliderControllerElement; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(SliderControllerElement.class) -public class SliderControllerElementMixin implements ComponentProcessorProvider { - @Unique private final SliderControllerElementComponentProcessor controlify$processor - = new SliderControllerElementComponentProcessor((SliderControllerElement) (Object) this); - - @Override - public ComponentProcessor componentProcessor() { - return controlify$processor; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/StringControllerElementMixin.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/StringControllerElementMixin.java deleted file mode 100644 index ffd082b3b..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/StringControllerElementMixin.java +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.mixins; - -import dev.isxander.controlify.compatibility.yacl.screenop.StringControllerElementComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.yacl3.gui.controllers.string.StringControllerElement; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(StringControllerElement.class) -public class StringControllerElementMixin implements ComponentProcessorProvider { - @Unique private final ComponentProcessor componentProcessor = new StringControllerElementComponentProcessor( - (StringControllerElement) (Object) this - ); - - @Override - public ComponentProcessor componentProcessor() { - return componentProcessor; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenMixin.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenMixin.java deleted file mode 100644 index 3515ed9f6..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/mixins/YACLScreenMixin.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.mixins; - -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.compatibility.yacl.screenop.YACLScreenProcessor; -import dev.isxander.yacl3.gui.YACLScreen; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(YACLScreen.class) -public class YACLScreenMixin implements ScreenProcessorProvider { - @Unique private final YACLScreenProcessor controlify$processor = new YACLScreenProcessor((YACLScreen) (Object) this); - - @Override - public ScreenProcessor screenProcessor() { - return controlify$processor; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/CyclingControllerElementComponentProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/CyclingControllerElementComponentProcessor.java deleted file mode 100644 index 1fb987ae8..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/CyclingControllerElementComponentProcessor.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.screenop; - -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.compatibility.yacl.mixins.ControllerWidgetAccessor; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.yacl3.gui.controllers.cycling.CyclingControllerElement; - -public class CyclingControllerElementComponentProcessor implements ComponentProcessor { - private final CyclingControllerElement cyclingController; - private boolean prevLeft, prevRight; - - - public CyclingControllerElementComponentProcessor(CyclingControllerElement cyclingController) { - this.cyclingController = cyclingController; - } - - @Override - public boolean overrideControllerNavigation(ScreenProcessor screen, ControllerEntity controller) { - boolean left = ControlifyBindings.GUI_SECONDARY_NAVI_LEFT.on(controller).digitalNow(); - boolean right = ControlifyBindings.GUI_SECONDARY_NAVI_RIGHT.on(controller).digitalNow(); - - if (!((ControllerWidgetAccessor) cyclingController).getControl().option().available()) { - return false; - } - - if (left && !prevLeft) { - prevLeft = true; - prevRight = false; - - cyclingController.cycleValue(-1); - - return true; - } else if (right && !prevRight) { - prevLeft = false; - prevRight = true; - - cyclingController.cycleValue(1); - - return true; - } else { - prevLeft = left; - prevRight = right; - - return false; - } - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/OptionListWidgetComponentProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/OptionListWidgetComponentProcessor.java deleted file mode 100644 index 79e4d6a6b..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/OptionListWidgetComponentProcessor.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.screenop; - -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.compatibility.yacl.mixins.GroupSeparatorEntryAccessor; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.yacl3.gui.OptionListWidget; -import org.jetbrains.annotations.Nullable; - -import java.util.List; - -public class OptionListWidgetComponentProcessor implements ComponentProcessor { - private final OptionListWidget optionListWidget; - - public OptionListWidgetComponentProcessor(OptionListWidget optionListWidget) { - this.optionListWidget = optionListWidget; - } - - /** - * Allows navigating between groups using secondary navigation up/down. - */ - @Override - public boolean overrideControllerNavigation(ScreenProcessor screen, ControllerEntity controller) { - boolean up = ControlifyBindings.GUI_SECONDARY_NAVI_UP.on(controller).justPressed(); - boolean down = ControlifyBindings.GUI_SECONDARY_NAVI_DOWN.on(controller).justPressed(); - if (up || down) { - OptionListWidget.GroupSeparatorEntry nextGroup = findNextGroup(optionListWidget, up ? -1 : 1); - if (nextGroup != null) { - if (nextGroup.isExpanded()) { - var childEntries = ((GroupSeparatorEntryAccessor) nextGroup).getChildEntries(); - if (!childEntries.isEmpty()) { - var childEntry = childEntries.get(0); - var buttonWithinChild = childEntry.children().get(0); - optionListWidget.setFocused(childEntries.get(0)); - childEntry.setFocused(buttonWithinChild); - } else { - optionListWidget.setFocused(nextGroup); - } - } else { - optionListWidget.setFocused(nextGroup); - } - return true; - } - } - - return false; - } - - private static @Nullable OptionListWidget.GroupSeparatorEntry findNextGroup(OptionListWidget optionList, int direction) { - List entries = optionList.children(); - OptionListWidget.Entry focusedEntry = optionList.getFocused(); - int currentIndex = entries.indexOf(focusedEntry); - if (direction < 0) currentIndex -= 2; - if (currentIndex < 0) currentIndex = 0; - - for (int i = currentIndex; i >= 0 && i < entries.size(); i += direction) { - OptionListWidget.Entry entry = entries.get(i); - if (entry instanceof OptionListWidget.GroupSeparatorEntry groupEntry) { - return groupEntry; - } - } - - return null; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/SliderControllerElementComponentProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/SliderControllerElementComponentProcessor.java deleted file mode 100644 index 4f396ebd4..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/SliderControllerElementComponentProcessor.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.screenop; - -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.compatibility.yacl.mixins.ControllerWidgetAccessor; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.utils.HoldRepeatHelper; -import dev.isxander.yacl3.gui.controllers.slider.SliderControllerElement; - -public class SliderControllerElementComponentProcessor implements ComponentProcessor { - private final SliderControllerElement slider; - private final HoldRepeatHelper holdRepeatHelper = new HoldRepeatHelper(15, 3); - - public SliderControllerElementComponentProcessor(SliderControllerElement element) { - this.slider = element; - } - - @Override - public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) { - var left = ControlifyBindings.GUI_SECONDARY_NAVI_LEFT.on(controller).digitalNow(); - var leftPrev = ControlifyBindings.GUI_SECONDARY_NAVI_LEFT.on(controller).digitalPrev(); - var right = ControlifyBindings.GUI_SECONDARY_NAVI_RIGHT.on(controller).digitalNow(); - var rightPrev = ControlifyBindings.GUI_SECONDARY_NAVI_RIGHT.on(controller).digitalPrev(); - - if (!((ControllerWidgetAccessor) slider).getControl().option().available()) { - return false; - } - - boolean repeatEventAvailable = holdRepeatHelper.canNavigate(); - - if (left && (repeatEventAvailable || !leftPrev)) { - slider.incrementValue(-1); - - if (!leftPrev) - holdRepeatHelper.reset(); - } else if (right && (repeatEventAvailable || !rightPrev)) { - slider.incrementValue(1); - - if (!rightPrev) - holdRepeatHelper.reset(); - } else { - return false; - } - - holdRepeatHelper.onNavigate(); - - return true; - } -} diff --git a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/YACLScreenProcessor.java b/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/YACLScreenProcessor.java deleted file mode 100644 index 9c9351fad..000000000 --- a/src/main/java/dev/isxander/controlify/compatibility/yacl/screenop/YACLScreenProcessor.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.compatibility.yacl.screenop; - -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.compatibility.yacl.mixins.YACLScreenCategoryTabAccessor; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.yacl3.gui.OptionListWidget; -import dev.isxander.yacl3.gui.WidgetAndType; -import dev.isxander.yacl3.gui.YACLScreen; - -public class YACLScreenProcessor extends ScreenProcessor { - public YACLScreenProcessor(YACLScreen screen) { - super(screen); - } - - @Override - protected void handleButtons(ControllerEntity controller) { - if (ControlifyBindings.GUI_ABSTRACT_ACTION_1.on(controller).justPressed()) { - if (screen.tabManager.getCurrentTab() instanceof YACLScreen.CategoryTab categoryTab) { - ((YACLScreenCategoryTabAccessor) categoryTab).getSaveFinishedButton().onPress(null); - } - playClackSound(); - } - - super.handleButtons(controller); - } - - @Override - protected void onTabChanged(ControllerEntity controller) { - if (screen.tabManager.getCurrentTab() instanceof YACLScreen.CategoryTab categoryTab) { - WidgetAndType optionListHolder = ((YACLScreenCategoryTabAccessor) categoryTab).getOptionList(); - OptionListWidget optionList = optionListHolder.getType(); - optionList.setScrollAmount(0); - - screen.setFocused(optionListHolder.getWidget()); - - if (optionList.children().isEmpty()) - return; - - for (OptionListWidget.Entry entry : optionList.children()) { - entry.setFocused(false); - entry.setFocused(null); - } - - OptionListWidget.Entry firstEntry = optionList.children().get(0); - optionList.setFocused(firstEntry); - - firstEntry.setFocused(firstEntry.children().get(0)); - } - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/ControllerEntity.java b/src/main/java/dev/isxander/controlify/controller/ControllerEntity.java deleted file mode 100644 index 412aabcbe..000000000 --- a/src/main/java/dev/isxander/controlify/controller/ControllerEntity.java +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller; - -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.controller.battery.BatteryLevelComponent; -import dev.isxander.controlify.controller.dualsense.DualsenseComponent; -import dev.isxander.controlify.controller.haptic.HDHapticComponent; -import dev.isxander.controlify.controller.info.ControllerInfo; -import dev.isxander.controlify.controller.info.DriverNameComponent; -import dev.isxander.controlify.controller.info.GUIDComponent; -import dev.isxander.controlify.controller.info.UIDComponent; -import dev.isxander.controlify.controller.led.LEDComponent; -import dev.isxander.controlify.controller.gyro.GyroComponent; -import dev.isxander.controlify.controller.impl.ECSEntityImpl; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.controller.misc.BluetoothDeviceComponent; -import dev.isxander.controlify.controller.rumble.RumbleComponent; -import dev.isxander.controlify.controller.rumble.TriggerRumbleComponent; -import dev.isxander.controlify.controller.touchpad.TouchpadComponent; -import dev.isxander.controlify.driver.Driver; -import dev.isxander.controlify.utils.log.ControlifyLogger; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; - -import java.util.Optional; -import java.util.stream.Collectors; - -public class ControllerEntity extends ECSEntityImpl { - private final ControllerInfo info; - private final Driver driver; - private ProfileSettings settings; - private final ProfileSettings defaultSettings; - private final ControlifyLogger logger; - - public ControllerEntity( - ControllerInfo info, - Driver driver, - ProfileSettings settings, - ProfileSettings defaultSettings, - ControlifyLogger logger - ) { - this.info = info; - this.driver = driver; - this.settings = settings; - this.defaultSettings = defaultSettings; - this.logger = logger; - - driver.addComponents(this); - this.getAllComponents().values().forEach(c -> c.attach(this)); - - logger.debugLog("Components: {}", this.getAllComponents().keySet().stream().map(Identifier::toString).collect(Collectors.joining(", "))); - } - - public String uid() { - return this.getComponent(UIDComponent.ID).orElseThrow().value(); - } - - @NotNull public String driverName() { - return this.getComponent(DriverNameComponent.ID).orElseThrow().value(); - } - - @NotNull public String guid() { - return this.getComponent(GUIDComponent.ID).orElseThrow().value(); - } - - public ControllerInfo info() { - return this.info; - } - - @NotNull public String name() { - String friendlyName = info().type().friendlyName(); - if (friendlyName != null) - return friendlyName; - - return driverName(); - } - - public Driver drivers() { - return driver; - } - - public ProfileSettings settings() { - return settings; - } - - public void setSettings(ProfileSettings settings) { - this.settings = settings; - } - - public ProfileSettings defaultSettings() { - return defaultSettings; - } - - @Contract(pure = true) - public Optional input() { - return this.getComponent(InputComponent.ID); - } - - @Contract(pure = true) - public Optional rumble() { - return this.getComponent(RumbleComponent.ID); - } - - @Contract(pure = true) - public Optional triggerRumble() { - return this.getComponent(TriggerRumbleComponent.ID); - } - - @Contract(pure = true) - public Optional gyro() { - return this.getComponent(GyroComponent.ID); - } - - @Contract(pure = true) - public Optional touchpad() { - return this.getComponent(TouchpadComponent.ID); - } - - @Contract(pure = true) - public Optional batteryLevel() { - return this.getComponent(BatteryLevelComponent.ID); - } - - @Contract(pure = true) - public Optional hdHaptics() { - return this.getComponent(HDHapticComponent.ID); - } - - @Contract(pure = true) - public Optional dualSense() { - return this.getComponent(DualsenseComponent.ID); - } - - @Contract(pure = true) - public Optional led() { - return this.getComponent(LEDComponent.ID); - } - - @Contract(pure = true) - public Optional bluetooth() { - return this.getComponent(BluetoothDeviceComponent.ID); - } - - public void update(boolean outOfFocus) { - this.driver.update(this, outOfFocus); - } - - public void close() { - this.driver.close(); - } - - public ControlifyLogger getLogger() { - return logger; - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/ECSComponent.java b/src/main/java/dev/isxander/controlify/controller/ECSComponent.java deleted file mode 100644 index 38b5b29e7..000000000 --- a/src/main/java/dev/isxander/controlify/controller/ECSComponent.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller; - -import net.minecraft.resources.Identifier; - -public interface ECSComponent { - Identifier id(); - - default void attach(ControllerEntity controller) { - - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/ECSEntity.java b/src/main/java/dev/isxander/controlify/controller/ECSEntity.java deleted file mode 100644 index e418b2487..000000000 --- a/src/main/java/dev/isxander/controlify/controller/ECSEntity.java +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller; - -import net.minecraft.resources.Identifier; - -import java.util.Map; -import java.util.Optional; - -public interface ECSEntity { - Map getAllComponents(); - - boolean setComponent(T component); - - boolean removeComponent(Identifier id); - - Optional getComponent(Identifier id); -} diff --git a/src/main/java/dev/isxander/controlify/controller/gyro/GyroCalibrator.java b/src/main/java/dev/isxander/controlify/controller/gyro/GyroCalibrator.java deleted file mode 100644 index 7b7bf3ebc..000000000 --- a/src/main/java/dev/isxander/controlify/controller/gyro/GyroCalibrator.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.gyro; - -/** - * Implements rolling gyro calibration using exponential moving average - * for background bias estimation. The calibrator continuously updates - * during stationary periods (low angular velocity) to track and correct - * for gyro drift over time. - */ -public class GyroCalibrator { - // Exponential moving average (EMA) alpha value - lower = slower adaptation, more stable - // 0.001 means it takes ~1000 samples to substantially update the bias estimate - private static final float EMA_ALPHA = 0.001f; - - // Threshold for detecting stationary state (rad/s) - // If all axes are below this threshold, we consider the gyro stationary - private static final float STATIONARY_THRESHOLD = 0.02f; - - // Minimum number of consecutive stationary samples before updating bias - private static final int MIN_STATIONARY_SAMPLES = 10; - - // Current bias estimate (offset to subtract from raw readings) - private final GyroState biasEstimate = new GyroState(0, 0, 0); - - // Counter for consecutive stationary samples - private int stationarySampleCount = 0; - - /** - * Update the calibration with a new gyro sample. - * This should be called every time new gyro data arrives. - * - * @param rawGyroState The raw gyro reading from SDL3 - */ - public void update(GyroStateC rawGyroState) { - // Check if the gyro appears to be stationary - boolean isStationary = isStationary(rawGyroState); - - if (isStationary) { - stationarySampleCount++; - - // Only update bias estimate if we've been stationary long enough - if (stationarySampleCount >= MIN_STATIONARY_SAMPLES) { - // Update bias estimate using exponential moving average - // New bias = (1 - alpha) * old_bias + alpha * current_reading - biasEstimate.x = (1 - EMA_ALPHA) * biasEstimate.x + EMA_ALPHA * rawGyroState.pitch(); - biasEstimate.y = (1 - EMA_ALPHA) * biasEstimate.y + EMA_ALPHA * rawGyroState.yaw(); - biasEstimate.z = (1 - EMA_ALPHA) * biasEstimate.z + EMA_ALPHA * rawGyroState.roll(); - } - } else { - // Reset stationary counter when motion is detected - stationarySampleCount = 0; - } - } - - /** - * Apply the current bias correction to a raw gyro reading. - * - * @param rawGyroState The raw gyro reading from SDL3 - * @return Calibrated gyro state with bias removed - */ - public GyroState applyCalibration(GyroStateC rawGyroState) { - return new GyroState( - rawGyroState.pitch() - biasEstimate.pitch(), - rawGyroState.yaw() - biasEstimate.yaw(), - rawGyroState.roll() - biasEstimate.roll() - ); - } - - /** - * Check if the gyro appears to be stationary based on angular velocity. - */ - private boolean isStationary(GyroStateC state) { - return Math.abs(state.pitch()) < STATIONARY_THRESHOLD - && Math.abs(state.yaw()) < STATIONARY_THRESHOLD - && Math.abs(state.roll()) < STATIONARY_THRESHOLD; - } - - /** - * Get the current bias estimate (for saving to config). - */ - public GyroStateC getCurrentBias() { - return new GyroState(this.biasEstimate); - } - - /** - * Set the bias estimate (for loading from config). - */ - public void setBias(GyroStateC bias) { - biasEstimate.set(bias.pitch(), bias.yaw(), bias.roll()); - stationarySampleCount = 0; - } - - /** - * Reset the calibrator to initial state. - */ - public void reset() { - biasEstimate.set(0, 0, 0); - stationarySampleCount = 0; - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/gyro/GyroComponent.java b/src/main/java/dev/isxander/controlify/controller/gyro/GyroComponent.java deleted file mode 100644 index 3a49a211b..000000000 --- a/src/main/java/dev/isxander/controlify/controller/gyro/GyroComponent.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.gyro; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.config.settings.profile.GyroSettings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; - -public class GyroComponent extends ECSComponentImpl { - public static final Identifier ID = CUtil.rl("gyro"); - - private GyroStateC gyroState = GyroStateC.ZERO; - private final GyroCalibrator calibrator = new GyroCalibrator(); - - // Save calibration every 5 seconds (100 ticks) - private static final int CALIBRATION_SAVE_INTERVAL = 100; - private int ticksSinceLastSave = 0; - private GyroStateC lastSavedBias = GyroStateC.ZERO; - - @Override - public void attach(ControllerEntity controller) { - super.attach(controller); - - // Load the calibration from device settings - DeviceSettings deviceSettings = Controlify.instance().config().getSettings() - .getOrCreateDeviceSettings(controller.uid()); - GyroStateC savedOffset = deviceSettings.gyroCalibration.offset; - - // Initialize the calibrator with the saved offset - calibrator.setBias(savedOffset); - lastSavedBias = savedOffset; - } - - public GyroStateC getState() { - return this.gyroState; - } - - public void setState(GyroStateC state) { - // Update the calibrator with the raw reading - calibrator.update(state); - // Store the calibrated state - this.gyroState = calibrator.applyCalibration(state); - - // Periodically save calibration if it has changed significantly - ticksSinceLastSave++; - if (ticksSinceLastSave >= CALIBRATION_SAVE_INTERVAL) { - ticksSinceLastSave = 0; - saveCalibrationIfChanged(); - } - } - - private void saveCalibrationIfChanged() { - GyroStateC currentBias = calibrator.getCurrentBias(); - - // Check if bias has changed significantly (more than 0.001 rad/s on any axis) - float threshold = 0.001f; - if (Math.abs(currentBias.pitch() - lastSavedBias.pitch()) > threshold || - Math.abs(currentBias.yaw() - lastSavedBias.yaw()) > threshold || - Math.abs(currentBias.roll() - lastSavedBias.roll()) > threshold) { - - // Save to device settings - DeviceSettings deviceSettings = Controlify.instance().config().getSettings() - .getOrCreateDeviceSettings(controller().uid()); - deviceSettings.gyroCalibration.offset = new GyroState(currentBias); - - // Mark config as dirty so it gets saved at some point - // We don't really care if it never gets saved if the user never changes any other config - // that triggers a save, we just recalibrate from last saved on next launch - Controlify.instance().config().markDirty(); - - lastSavedBias = currentBias; - } - } - - public GyroCalibrator getCalibrator() { - return this.calibrator; - } - - public GyroSettings settings() { - return this.controller().settings().gyro; - } - - public GyroSettings defaultSettings() { - return this.controller().defaultSettings().gyro; - } - - @Override - public Identifier id() { - return ID; - } - -} diff --git a/src/main/java/dev/isxander/controlify/controller/gyro/GyroState.java b/src/main/java/dev/isxander/controlify/controller/gyro/GyroState.java deleted file mode 100644 index 8d687dd04..000000000 --- a/src/main/java/dev/isxander/controlify/controller/gyro/GyroState.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.gyro; - -import org.joml.Vector3f; -import org.joml.Vector3fc; - -public class GyroState extends Vector3f implements GyroStateC { - public GyroState(float pitch, float yaw, float roll) { - super(pitch, yaw, roll); - } - - public GyroState(GyroStateC vec) { - super(vec); - } - - public GyroState() { - } - - @Override - public GyroState mul(Vector3fc v) { - super.mul(v); - return this; - } - - @Override - public GyroState mul(float scalar) { - super.mul(scalar); - return this; - } - - @Override - public GyroState div(Vector3fc v) { - super.div(v); - return this; - } - - @Override - public GyroState div(float scalar) { - super.div(scalar); - return this; - } - - @Override - public GyroState sub(Vector3fc v) { - super.sub(v); - return this; - } - - @Override - public GyroState sub(float x, float y, float z) { - super.sub(x, y, z); - return this; - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/gyro/GyroStateC.java b/src/main/java/dev/isxander/controlify/controller/gyro/GyroStateC.java deleted file mode 100644 index eabbc8f1a..000000000 --- a/src/main/java/dev/isxander/controlify/controller/gyro/GyroStateC.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.gyro; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import org.joml.Vector3fc; - -/** - * Represents the current gyro delta in radians per second. - */ -public interface GyroStateC extends Vector3fc { - GyroStateC ZERO = new GyroState(0, 0, 0); - - default float pitch() { - return x(); - } - - default float yaw() { - return y(); - } - - default float roll() { - return z(); - } - - Codec CODEC_MUTABLE = RecordCodecBuilder.create(instance -> instance.group( - Codec.FLOAT.fieldOf("pitch").forGetter(GyroStateC::pitch), - Codec.FLOAT.fieldOf("yaw").forGetter(GyroStateC::yaw), - Codec.FLOAT.fieldOf("roll").forGetter(GyroStateC::roll) - ).apply(instance, GyroState::new)); -} diff --git a/src/main/java/dev/isxander/controlify/controller/id/ControllerType.java b/src/main/java/dev/isxander/controlify/controller/id/ControllerType.java deleted file mode 100644 index 13ee74c1d..000000000 --- a/src/main/java/dev/isxander/controlify/controller/id/ControllerType.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.id; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.MapCodec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.driver.steamdeck.SteamDeckUtil; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; - -public record ControllerType(@Nullable String friendlyName, String mappingId, Identifier namespace, boolean forceJoystick, boolean dontLoad) { - public static final ControllerType DEFAULT = new ControllerType(null, "default", CUtil.rl("default"), false, false); - - public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( - Codec.STRING.optionalFieldOf("name", null).forGetter(ControllerType::friendlyName), - Codec.STRING.optionalFieldOf("mapping", DEFAULT.mappingId()).forGetter(ControllerType::mappingId), - Identifier.CODEC.optionalFieldOf("namespace", DEFAULT.namespace()).forGetter(ControllerType::namespace), - Codec.BOOL.optionalFieldOf("force_joystick", false).forGetter(ControllerType::forceJoystick), - Codec.BOOL.optionalFieldOf("dont_load", false).forGetter(ControllerType::dontLoad) - ).apply(instance, ControllerType::new)); - - public Identifier getIconSprite() { - return namespace.withPrefix("controllers/"); - } - - public boolean isSteamDeck() { - return namespace.equals(SteamDeckUtil.STEAM_DECK_NAMESPACE); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/impl/ControllerStateImpl.java b/src/main/java/dev/isxander/controlify/controller/impl/ControllerStateImpl.java deleted file mode 100644 index c7f7abdad..000000000 --- a/src/main/java/dev/isxander/controlify/controller/impl/ControllerStateImpl.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.impl; - -import com.google.common.base.Joiner; -import dev.isxander.controlify.controller.input.HatState; -import dev.isxander.controlify.controller.input.ModifiableControllerState; -import it.unimi.dsi.fastutil.objects.Object2BooleanArrayMap; -import it.unimi.dsi.fastutil.objects.Object2FloatArrayMap; -import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap; -import net.minecraft.resources.Identifier; - -import java.util.Map; -import java.util.Set; - -public class ControllerStateImpl implements ModifiableControllerState { - private final Map buttons; - private final Map axes; - private final Map restingAxes; - private final Map hats; - - public ControllerStateImpl() { - this.buttons = new Object2BooleanArrayMap<>(); - this.axes = new Object2FloatArrayMap<>(); - this.restingAxes = new Object2FloatArrayMap<>(); - this.hats = new Object2ObjectArrayMap<>(); - } - - @Override - public boolean isButtonDown(Identifier button) { - return buttons.getOrDefault(button, false); - } - - @Override - public Set getButtons() { - return buttons.keySet(); - } - - @Override - public float getAxisState(Identifier axis) { - return axes.getOrDefault(axis, 0f); - } - - @Override - public Set getAxes() { - return axes.keySet(); - } - - @Override - public float getAxisResting(Identifier axis) { - return restingAxes.getOrDefault(axis, 0f); - } - - @Override - public HatState getHatState(Identifier hat) { - return hats.getOrDefault(hat, HatState.CENTERED); - } - - @Override - public Set getHats() { - return hats.keySet(); - } - - @Override - public void setButton(Identifier button, boolean value) { - buttons.put(button, value); - } - - @Override - public void setAxis(Identifier axis, float value) { - axes.put(axis, value); - } - - public void setRestingAxis(Identifier axis, float value) { - if (!axes.containsKey(axis)) - throw new IllegalArgumentException("Cannot set resting axis for axis that doesn't exist"); - - restingAxes.put(axis, value); - } - - @Override - public void setHat(Identifier hat, HatState value) { - hats.put(hat, value); - } - - @Override - public void clearState() { - this.buttons.clear(); - this.axes.clear(); - this.restingAxes.clear(); - this.hats.clear(); - } - - public String toDebugString() { - Joiner.MapJoiner joiner = Joiner.on(",").withKeyValueSeparator("="); - return "ControllerState{axes:%s,buttons:%s,hats:%s}".formatted( - joiner.join(axes), - joiner.join(buttons), - joiner.join(hats) - ); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/impl/ECSComponentImpl.java b/src/main/java/dev/isxander/controlify/controller/impl/ECSComponentImpl.java deleted file mode 100644 index ee13dfaa4..000000000 --- a/src/main/java/dev/isxander/controlify/controller/impl/ECSComponentImpl.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.impl; - -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.ECSComponent; - -public abstract class ECSComponentImpl implements ECSComponent { - private ControllerEntity controller; - - protected final ControllerEntity controller() { - return this.controller; - } - - @Override - public void attach(ControllerEntity controller) { - this.controller = controller; - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/impl/ECSEntityImpl.java b/src/main/java/dev/isxander/controlify/controller/impl/ECSEntityImpl.java deleted file mode 100644 index 5b65446ed..000000000 --- a/src/main/java/dev/isxander/controlify/controller/impl/ECSEntityImpl.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.impl; - -import com.google.common.collect.ImmutableMap; -import dev.isxander.controlify.controller.ECSComponent; -import dev.isxander.controlify.controller.ECSEntity; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Contract; - -import java.util.*; - -public class ECSEntityImpl implements ECSEntity { - private final Map components; - - public ECSEntityImpl() { - this.components = new HashMap<>(); - } - - @SuppressWarnings("unchecked") - @Override - @Contract(pure = true) - public Optional getComponent(Identifier id) { - return Optional.ofNullable((T) this.components.get(id)); - } - - @Override - public boolean setComponent(T component) { - return this.components.put(component.id(), component) != null; - } - - @Override - public boolean removeComponent(Identifier id) { - return this.components.remove(id) != null; - } - - @Override - public Map getAllComponents() { - return ImmutableMap.copyOf(this.components); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/info/ControllerInfo.java b/src/main/java/dev/isxander/controlify/controller/info/ControllerInfo.java deleted file mode 100644 index d1e3433a1..000000000 --- a/src/main/java/dev/isxander/controlify/controller/info/ControllerInfo.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.info; - -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.controllermanager.UniqueControllerID; -import dev.isxander.controlify.hid.HIDDevice; - -import java.util.Optional; - -public record ControllerInfo(UniqueControllerID ucid, ControllerType type, Optional hid) { -} diff --git a/src/main/java/dev/isxander/controlify/controller/info/DriverNameComponent.java b/src/main/java/dev/isxander/controlify/controller/info/DriverNameComponent.java deleted file mode 100644 index 4862ae08a..000000000 --- a/src/main/java/dev/isxander/controlify/controller/info/DriverNameComponent.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.info; - -import dev.isxander.controlify.controller.SingleValueComponent; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; - -public class DriverNameComponent extends SingleValueComponent { - public static final Identifier ID = CUtil.rl("driver_name"); - - public DriverNameComponent(String value) { - super(value, ID); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/info/GUIDComponent.java b/src/main/java/dev/isxander/controlify/controller/info/GUIDComponent.java deleted file mode 100644 index 8fa487c0b..000000000 --- a/src/main/java/dev/isxander/controlify/controller/info/GUIDComponent.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.info; - -import dev.isxander.controlify.controller.SingleValueComponent; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; - -public class GUIDComponent extends SingleValueComponent { - public static final Identifier ID = CUtil.rl("guid"); - - public GUIDComponent(String value) { - super(value, ID); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/info/UIDComponent.java b/src/main/java/dev/isxander/controlify/controller/info/UIDComponent.java deleted file mode 100644 index 04c71f83b..000000000 --- a/src/main/java/dev/isxander/controlify/controller/info/UIDComponent.java +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.info; - -import dev.isxander.controlify.controller.SingleValueComponent; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; - -public class UIDComponent extends SingleValueComponent { - public static final Identifier ID = CUtil.rl("uid"); - - public UIDComponent(String value) { - super(value, ID); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/ControllerState.java b/src/main/java/dev/isxander/controlify/controller/input/ControllerState.java deleted file mode 100644 index fbabdfc10..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/ControllerState.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input; - -import dev.isxander.controlify.controller.impl.ControllerStateImpl; - -public interface ControllerState extends ControllerStateView { - ControllerState EMPTY = new ControllerStateImpl(); - - void clearState(); - - default void close() {} -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/ControllerStateView.java b/src/main/java/dev/isxander/controlify/controller/input/ControllerStateView.java deleted file mode 100644 index 46141b247..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/ControllerStateView.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input; - -import net.minecraft.resources.Identifier; - -import java.util.Set; - -public interface ControllerStateView { - boolean isButtonDown(Identifier button); - - Set getButtons(); - - float getAxisState(Identifier axis); - Set getAxes(); - - float getAxisResting(Identifier axis); - - HatState getHatState(Identifier hat); - Set getHats(); - - default boolean isGivingInput() { - return getButtons().stream().anyMatch(this::isButtonDown) - || getAxes().stream().map(this::getAxisState).anyMatch(axis -> Math.abs(axis) > 0.5f) - || getHats().stream().map(this::getHatState).anyMatch(hat -> hat != HatState.CENTERED); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/DeadzoneControllerStateView.java b/src/main/java/dev/isxander/controlify/controller/input/DeadzoneControllerStateView.java deleted file mode 100644 index 2a1f62e41..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/DeadzoneControllerStateView.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input; - -import dev.isxander.controlify.utils.ControllerUtils; -import net.minecraft.resources.Identifier; - -import java.util.Optional; -import java.util.Set; - -public class DeadzoneControllerStateView implements ControllerStateView { - private final ControllerStateView view; - private final InputComponent input; - - public DeadzoneControllerStateView(ControllerStateView view, InputComponent input) { - this.view = view; - this.input = input; - } - - @Override - public boolean isButtonDown(Identifier button) { - return view.isButtonDown(button); - } - - @Override - public Set getButtons() { - return view.getButtons(); - } - - @Override - public float getAxisState(Identifier axis) { - float rawAxis = view.getAxisState(axis); - Optional deadzoneId = input.getDeadzoneForAxis(axis); - float deadzone = deadzoneId.map(id -> input.settings().sensitivity.getDeadzone(id)).orElse(0f); - - return ControllerUtils.deadzone(rawAxis, deadzone); - } - - @Override - public Set getAxes() { - return view.getAxes(); - } - - @Override - public float getAxisResting(Identifier axis) { - return view.getAxisResting(axis); - } - - @Override - public HatState getHatState(Identifier hat) { - return view.getHatState(hat); - } - - @Override - public Set getHats() { - return view.getHats(); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/DeadzoneGroup.java b/src/main/java/dev/isxander/controlify/controller/input/DeadzoneGroup.java deleted file mode 100644 index 773aef561..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/DeadzoneGroup.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import net.minecraft.resources.Identifier; - -import java.util.List; - -public record DeadzoneGroup( - Identifier name, - List axes -) { - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("name").forGetter(DeadzoneGroup::name), - Identifier.CODEC.listOf().fieldOf("axes").forGetter(DeadzoneGroup::axes) - ).apply(instance, DeadzoneGroup::new)); -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/HatState.java b/src/main/java/dev/isxander/controlify/controller/input/HatState.java deleted file mode 100644 index bd1068f69..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/HatState.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input; - -import com.mojang.serialization.Codec; -import dev.isxander.yacl3.api.NameableEnum; -import net.minecraft.network.chat.Component; -import net.minecraft.util.StringRepresentable; -import org.jetbrains.annotations.NotNull; - -public enum HatState implements NameableEnum, StringRepresentable { - CENTERED, - UP, - RIGHT, - DOWN, - LEFT, - RIGHT_UP, - RIGHT_DOWN, - LEFT_UP, - LEFT_DOWN; - - public static final Codec CODEC = StringRepresentable.fromEnum(HatState::values); - - public boolean isCentered() { - return this == CENTERED; - } - - public boolean isRight() { - return this == RIGHT || this == RIGHT_UP || this == RIGHT_DOWN; - } - - public boolean isUp() { - return this == UP || this == RIGHT_UP || this == LEFT_UP; - } - - public boolean isLeft() { - return this == LEFT || this == LEFT_UP || this == LEFT_DOWN; - } - - public boolean isDown() { - return this == DOWN || this == RIGHT_DOWN || this == LEFT_DOWN; - } - - @Override - public Component getDisplayName() { - return Component.translatable("controlify.hat_state." + this.name().toLowerCase()); - } - - @Override - public @NotNull String getSerializedName() { - return this.name(); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/InputComponent.java b/src/main/java/dev/isxander/controlify/controller/input/InputComponent.java deleted file mode 100644 index 61ae21e19..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/InputComponent.java +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.bindings.ControlifyBindApiImpl; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.config.settings.profile.InputSettings; -import dev.isxander.controlify.controller.*; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.controller.input.mapping.ControllerMapping; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.function.Function; -import java.util.stream.Collectors; - -public class InputComponent extends ECSComponentImpl { - public static final Identifier ID = CUtil.rl("input"); - - private final ControllerEntity controller; - private DeviceSettings deviceSettings; // Cached reference - - private ControllerState - stateNow = ControllerState.EMPTY, - stateThen = ControllerState.EMPTY; - private DeadzoneControllerStateView - deadzoneStateNow, - deadzoneStateThen; - - private final int buttonCount, axisCount, hatCount; - private final Map deadzoneAxes; - private final boolean definitelyGamepad; - - private final Map inputBindings; - - public InputComponent( - ControllerEntity controller, - int buttonCount, int axisCount, int hatCount, - boolean definitelyGamepad, - Set deadzoneAxes, - String mappingId - ) { - this.controller = controller; - this.buttonCount = buttonCount; - this.axisCount = axisCount; - this.hatCount = hatCount; - this.definitelyGamepad = definitelyGamepad; - this.deadzoneAxes = deadzoneAxes.stream() - .collect(Collectors.toMap( - DeadzoneGroup::name, - Function.identity(), - (x, y) -> y, - LinkedHashMap::new - )); - this.inputBindings = new LinkedHashMap<>(); - - this.updateDeadzoneView(); - } - - public ControllerStateView stateNow() { - return this.deadzoneStateNow; - } - public ControllerStateView stateThen() { - return this.deadzoneStateThen; - } - - public ControllerState rawStateNow() { - return this.stateNow; - } - - public ControllerState rawStateThen() { - return this.stateThen; - } - - public void pushState(ControllerState state) { - ControllerMapping mapping = getDeviceMapping(); - if (mapping != null) { - state = mapping.mapState(state); - } - - this.stateThen = this.stateNow; - this.stateNow = state; - this.updateDeadzoneView(); - - for (InputBinding binding : this.inputBindings.values()) { - binding.pushState(this.deadzoneStateNow); - } - } - - public @Nullable InputBinding getBinding(Identifier id) { - return this.inputBindings.get(id); - } - - public Collection getAllBindings() { - return this.inputBindings.values(); - } - - public void notifyGuiPressOutputsOfNavigate() { - for (InputBinding binding : this.inputBindings.values()) { - binding.guiPressed().onNavigate(); - } - } - - @Override - public void attach(ControllerEntity controller) { - super.attach(controller); - - // Cache device settings reference - this.deviceSettings = Controlify.instance().config().getSettings() - .getOrCreateDeviceSettings(controller.uid()); - - // Populate bindings - for (InputBinding binding : ControlifyBindApiImpl.INSTANCE.provideBindsForController(controller)) { - this.inputBindings.put(binding.id(), binding); - } - - // Note: No need to copy bound inputs from config here since InputBindingImpl.boundInput() - // now reads directly from the config (via inputComponent.settings().bindings.bindings) - } - - public int buttonCount() { - return this.buttonCount; - } - public int axisCount() { - return this.axisCount; - } - public int hatCount() { - return this.hatCount; - } - - public boolean isDefinitelyGamepad() { - return this.definitelyGamepad; - } - - public Map getDeadzoneGroups() { - ControllerMapping mapping = getDeviceMapping(); - if (mapping != null) { - return mapping.deadzones(); - } else { - return this.deadzoneAxes; - } - } - - private @Nullable ControllerMapping getDeviceMapping() { - // Use cached device settings to avoid repeated lookups - return deviceSettings.mapping; - } - - public ControllerEntity getController() { - return controller(); - } - - public InputSettings settings() { - return this.controller().settings().input; - } - - public InputSettings defaultSettings() { - return this.controller().defaultSettings().input; - } - - @Override - public Identifier id() { - return ID; - } - - private void updateDeadzoneView() { - this.deadzoneStateNow = new DeadzoneControllerStateView(this.stateNow, this); - this.deadzoneStateThen = new DeadzoneControllerStateView(this.stateThen, this); - } - - Optional getDeadzoneForAxis(Identifier axis) { - for (DeadzoneGroup group : this.getDeadzoneGroups().values()) { - if (group.axes().contains(axis)) { - return Optional.of(group.name()); - } - } - return Optional.empty(); - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/ModifiableControllerState.java b/src/main/java/dev/isxander/controlify/controller/input/ModifiableControllerState.java deleted file mode 100644 index 2f7b7ee64..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/ModifiableControllerState.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input; - -import net.minecraft.resources.Identifier; - -public interface ModifiableControllerState extends ControllerState { - void setButton(Identifier button, boolean pressed); - - void setAxis(Identifier axis, float value); - - void setHat(Identifier hat, HatState state); -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/mapping/ControllerMapping.java b/src/main/java/dev/isxander/controlify/controller/input/mapping/ControllerMapping.java deleted file mode 100644 index bdf7bac7f..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/mapping/ControllerMapping.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input.mapping; - -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.input.ControllerState; -import dev.isxander.controlify.controller.input.DeadzoneGroup; -import dev.isxander.controlify.controller.input.ModifiableControllerState; -import dev.isxander.controlify.controller.impl.ControllerStateImpl; -import net.minecraft.resources.Identifier; - -import java.util.*; - -public record ControllerMapping( - List mappings, - LinkedHashMap deadzones -) implements StateMapper { - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - MappingEntry.codec() - .listOf() - .fieldOf("mappings") - .forGetter(ControllerMapping::mappings), - Codec.unboundedMap(Identifier.CODEC, DeadzoneGroup.CODEC) - .xmap(LinkedHashMap::new, map -> map) - .fieldOf("deadzones") - .forGetter(ControllerMapping::deadzones) - ).apply(instance, ControllerMapping::new)); - - @Override - public ControllerState mapState(ControllerState state) { - if (mappings.isEmpty()) { - return state; - } - - ModifiableControllerState newState = new ControllerStateImpl(); - - for (MappingEntry mapping : mappings) { - mapping.apply(state, newState); - } - - return newState; - } - - public static final ControllerMapping NO_MAPPING = new Builder().build(); - - public static class Builder { - private final List mappings = new ArrayList<>(); - private final LinkedHashMap deadzones = new LinkedHashMap<>(); - - public Builder putMapping(MappingEntry mapping) { - if (mapping == null) - return this; - - mappings.add(mapping); - return this; - } - - public Builder putDeadzoneGroups(Iterable deadzoneGroup) { - for (DeadzoneGroup group : deadzoneGroup) { - this.deadzones.put(group.name(), group); - } - return this; - } - - public ControllerMapping build() { - return new ControllerMapping(mappings, deadzones); - } - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/mapping/ControllerMappingStorage.java b/src/main/java/dev/isxander/controlify/controller/input/mapping/ControllerMappingStorage.java deleted file mode 100644 index 144aa49a7..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/mapping/ControllerMappingStorage.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input.mapping; - -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import com.mojang.serialization.DataResult; -import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.utils.CUtil; -import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; -import net.minecraft.client.Minecraft; -import net.minecraft.server.packs.resources.Resource; -import net.minecraft.server.packs.resources.ResourceManager; -import org.jetbrains.annotations.Nullable; - -import java.io.BufferedReader; -import java.io.IOException; -import java.util.Map; - -public class ControllerMappingStorage { - private static final Map MAPPINGS = new Object2ObjectOpenHashMap<>(); - - public static @Nullable ControllerMapping get(String id) { - return MAPPINGS.computeIfAbsent(id, ControllerMappingStorage::resolve); - } - - private static @Nullable ControllerMapping resolve(String id) { - ResourceManager resourceManager = Minecraft.getInstance().getResourceManager(); - Resource resource = resourceManager - .getResource(CUtil.rl("mappings/" + id + ".json")) - .orElse(null); - if (resource == null) - return null; - - try (BufferedReader reader = resource.openAsReader()) { - JsonElement jsonElement = JsonParser.parseReader(reader); - DataResult result = ControllerMapping.CODEC.parse(JsonOps.INSTANCE, jsonElement); - return result.getOrThrow(); - } catch (IOException e) { - throw new IllegalStateException("Failed to load controller mapping!", e); - } - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntry.java b/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntry.java deleted file mode 100644 index 696f62c2f..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntry.java +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input.mapping; - -import com.mojang.serialization.Codec; -import dev.isxander.controlify.controller.input.ControllerState; -import dev.isxander.controlify.controller.input.HatState; -import dev.isxander.controlify.controller.input.ModifiableControllerState; -import dev.isxander.controlify.utils.MthExt; -import net.minecraft.resources.Identifier; - -public sealed interface MappingEntry { - void apply(ControllerState oldState, ModifiableControllerState newState); - - MapType inputType(); - - MapType outputType(); - - static Codec codec() { - return MappingEntryCodecs.CODEC; - } - - sealed interface FromButton extends MappingEntry { - record ToButton(Identifier from, Identifier to, boolean invert, MapType inputType, MapType outputType) implements FromButton { - public ToButton(Identifier from, Identifier to, boolean invert) { - this(from, to, invert, MapType.BUTTON, MapType.BUTTON); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - boolean fromState = oldState.isButtonDown(from); - if (invert()) fromState = !fromState; - newState.setButton(to, fromState); - } - } - - record ToAxis(Identifier from, Identifier to, float offState, float onState, MapType inputType, MapType outputType) implements FromButton { - public ToAxis(Identifier from, Identifier to, float offState, float onState) { - this(from, to, offState, onState, MapType.BUTTON, MapType.AXIS); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setAxis(to, oldState.isButtonDown(from) ? onState : offState); - } - } - - record ToHat(Identifier from, Identifier to, HatState offState, HatState onState, MapType inputType, MapType outputType) implements FromButton { - public ToHat(Identifier from, Identifier to, HatState offState, HatState onState) { - this(from, to, offState, onState, MapType.BUTTON, MapType.HAT); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setHat(to, oldState.isButtonDown(from) ? onState : offState); - } - } - } - - sealed interface FromAxis extends MappingEntry { - record ToButton(Identifier from, Identifier to, float threshold, MapType inputType, MapType outputType) implements FromAxis { - public ToButton(Identifier from, Identifier to, float threshold) { - this(from, to, threshold, MapType.AXIS, MapType.BUTTON); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setButton(to, oldState.getAxisState(from) >= threshold); - } - } - - record ToAxis(Identifier from, Identifier to, float minIn, float minOut, float maxIn, float maxOut, MapType inputType, MapType outputType) implements FromAxis { - public ToAxis(Identifier from, Identifier to, float minIn, float minOut, float maxIn, float maxOut) { - this(from, to, minIn, minOut, maxIn, maxOut, MapType.AXIS, MapType.AXIS); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - float oldVal = oldState.getAxisState(from); - float newVal = MthExt.remap(oldVal, minIn, maxIn, minOut, maxOut); - newState.setAxis(to, newVal); - } - } - - record ToHat(Identifier from, Identifier to, float threshold, HatState targetState, MapType inputType, MapType outputType) implements FromAxis { - public ToHat(Identifier from, Identifier to, float threshold, HatState targetState) { - this(from, to, threshold, targetState, MapType.AXIS, MapType.HAT); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - float oldVal = oldState.getAxisState(from); - newState.setHat(to, oldVal >= threshold ? targetState : HatState.CENTERED); - } - } - } - - sealed interface FromHat extends MappingEntry { - record ToButton(Identifier from, Identifier to, HatState targetState, MapType inputType, MapType outputType) implements FromHat { - public ToButton(Identifier from, Identifier to, HatState targetState) { - this(from, to, targetState, MapType.HAT, MapType.BUTTON); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setButton(to, oldState.getHatState(from) == targetState); - } - } - - record ToAxis(Identifier from, Identifier to, HatState targetState, float onState, float offState, MapType inputType, MapType outputType) implements FromHat { - public ToAxis(Identifier from, Identifier to, HatState targetState, float onState, float offState) { - this(from, to, targetState, onState, offState, MapType.HAT, MapType.AXIS); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setAxis(to, oldState.getHatState(from) == targetState ? onState : offState); - } - } - - record ToHat(Identifier from, Identifier to, MapType inputType, MapType outputType) implements FromHat { - public ToHat(Identifier from, Identifier to) { - this(from, to, MapType.HAT, MapType.HAT); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setHat(to, oldState.getHatState(from)); - } - } - } - - sealed interface FromNothing extends MappingEntry { - record ToButton(Identifier to, boolean state, MapType inputType, MapType outputType) implements FromNothing { - public ToButton(Identifier to, boolean state) { - this(to, state, MapType.NOTHING, MapType.BUTTON); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setButton(to, state); - } - } - - record ToAxis(Identifier to, float state, MapType inputType, MapType outputType) implements FromNothing { - public ToAxis(Identifier to, float state) { - this(to, state, MapType.NOTHING, MapType.AXIS); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setAxis(to, state); - } - } - - record ToHat(Identifier to, MapType inputType, MapType outputType) implements FromNothing { - public ToHat(Identifier to) { - this(to, MapType.NOTHING, MapType.HAT); - } - - @Override - public void apply(ControllerState oldState, ModifiableControllerState newState) { - newState.setHat(to, HatState.CENTERED); - } - } - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntryCodecs.java b/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntryCodecs.java deleted file mode 100644 index 2f3c09253..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntryCodecs.java +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input.mapping; - -import com.mojang.datafixers.util.Pair; -import com.mojang.serialization.Codec; -import com.mojang.serialization.DataResult; -import com.mojang.serialization.DynamicOps; -import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.input.HatState; -import net.minecraft.resources.Identifier; - -final class MappingEntryCodecs { - - private static final Codec CODEC_STUB = RecordCodecBuilder.create(instance -> instance.group( - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntryStub::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntryStub::outputType) - ).apply(instance, MappingEntryStub::new)); - - public static final Codec CODEC_BUTTON_TO_BUTTON = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromButton.ToButton::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromButton.ToButton::to), - Codec.BOOL.fieldOf("invert").forGetter(MappingEntry.FromButton.ToButton::invert), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromButton.ToButton::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromButton.ToButton::outputType) - ).apply(instance, MappingEntry.FromButton.ToButton::new)); - - public static final Codec CODEC_BUTTON_TO_AXIS = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromButton.ToAxis::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromButton.ToAxis::to), - Codec.FLOAT.fieldOf("off_state").forGetter(MappingEntry.FromButton.ToAxis::offState), - Codec.FLOAT.fieldOf("on_state").forGetter(MappingEntry.FromButton.ToAxis::onState), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromButton.ToAxis::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromButton.ToAxis::outputType) - ).apply(instance, MappingEntry.FromButton.ToAxis::new)); - - public static final Codec CODEC_BUTTON_TO_HAT = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromButton.ToHat::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromButton.ToHat::to), - HatState.CODEC.fieldOf("off_state").forGetter(MappingEntry.FromButton.ToHat::offState), - HatState.CODEC.fieldOf("on_state").forGetter(MappingEntry.FromButton.ToHat::onState), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromButton.ToHat::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromButton.ToHat::outputType) - ).apply(instance, MappingEntry.FromButton.ToHat::new)); - - public static final Codec CODEC_AXIS_TO_BUTTON = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromAxis.ToButton::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromAxis.ToButton::to), - Codec.FLOAT.fieldOf("threshold").forGetter(MappingEntry.FromAxis.ToButton::threshold), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromAxis.ToButton::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromAxis.ToButton::outputType) - ).apply(instance, MappingEntry.FromAxis.ToButton::new)); - - public static final Codec CODEC_AXIS_TO_AXIS = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromAxis.ToAxis::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromAxis.ToAxis::to), - Codec.FLOAT.fieldOf("min_in").forGetter(MappingEntry.FromAxis.ToAxis::minIn), - Codec.FLOAT.fieldOf("min_out").forGetter(MappingEntry.FromAxis.ToAxis::minOut), - Codec.FLOAT.fieldOf("max_in").forGetter(MappingEntry.FromAxis.ToAxis::maxIn), - Codec.FLOAT.fieldOf("max_out").forGetter(MappingEntry.FromAxis.ToAxis::maxOut), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromAxis.ToAxis::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromAxis.ToAxis::outputType) - ).apply(instance, MappingEntry.FromAxis.ToAxis::new)); - - public static final Codec CODEC_AXIS_TO_HAT = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromAxis.ToHat::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromAxis.ToHat::to), - Codec.FLOAT.fieldOf("threshold").forGetter(MappingEntry.FromAxis.ToHat::threshold), - HatState.CODEC.fieldOf("target_state").forGetter(MappingEntry.FromAxis.ToHat::targetState), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromAxis.ToHat::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromAxis.ToHat::outputType) - ).apply(instance, MappingEntry.FromAxis.ToHat::new)); - - public static final Codec CODEC_HAT_TO_BUTTON = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromHat.ToButton::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromHat.ToButton::to), - HatState.CODEC.fieldOf("target_state").forGetter(MappingEntry.FromHat.ToButton::targetState), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromHat.ToButton::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromHat.ToButton::outputType) - ).apply(instance, MappingEntry.FromHat.ToButton::new)); - - public static final Codec CODEC_HAT_TO_AXIS = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromHat.ToAxis::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromHat.ToAxis::to), - HatState.CODEC.fieldOf("target_state").forGetter(MappingEntry.FromHat.ToAxis::targetState), - Codec.FLOAT.fieldOf("on_state").forGetter(MappingEntry.FromHat.ToAxis::onState), - Codec.FLOAT.fieldOf("off_state").forGetter(MappingEntry.FromHat.ToAxis::offState), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromHat.ToAxis::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromHat.ToAxis::outputType) - ).apply(instance, MappingEntry.FromHat.ToAxis::new)); - - public static final Codec CODEC_HAT_TO_HAT = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromHat.ToHat::from), - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromHat.ToHat::to), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromHat.ToHat::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromHat.ToHat::outputType) - ).apply(instance, MappingEntry.FromHat.ToHat::new)); - - public static final Codec CODEC_NOTHING_TO_BUTTON = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromNothing.ToButton::to), - Codec.BOOL.fieldOf("state").forGetter(MappingEntry.FromNothing.ToButton::state), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromNothing.ToButton::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromNothing.ToButton::outputType) - ).apply(instance, MappingEntry.FromNothing.ToButton::new)); - - public static final Codec CODEC_NOTHING_TO_AXIS = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromNothing.ToAxis::to), - Codec.FLOAT.fieldOf("state").forGetter(MappingEntry.FromNothing.ToAxis::state), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromNothing.ToAxis::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromNothing.ToAxis::outputType) - ).apply(instance, MappingEntry.FromNothing.ToAxis::new)); - - public static final Codec CODEC_NOTHING_TO_HAT = RecordCodecBuilder.create(instance -> instance.group( - Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromNothing.ToHat::to), - MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromNothing.ToHat::inputType), - MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromNothing.ToHat::outputType) - ).apply(instance, MappingEntry.FromNothing.ToHat::new)); - - public static final Codec CODEC = new Codec<>() { - @Override - public DataResult encode(MappingEntry input, DynamicOps ops, T prefix) { - return switch (input) { - case MappingEntry.FromButton.ToButton e -> CODEC_BUTTON_TO_BUTTON.encode(e, ops, prefix); - case MappingEntry.FromButton.ToAxis e -> CODEC_BUTTON_TO_AXIS.encode(e, ops, prefix); - case MappingEntry.FromButton.ToHat e -> CODEC_BUTTON_TO_HAT.encode(e, ops, prefix); - case MappingEntry.FromAxis.ToButton e -> CODEC_AXIS_TO_BUTTON.encode(e, ops, prefix); - case MappingEntry.FromAxis.ToAxis e -> CODEC_AXIS_TO_AXIS.encode(e, ops, prefix); - case MappingEntry.FromAxis.ToHat e -> CODEC_AXIS_TO_HAT.encode(e, ops, prefix); - case MappingEntry.FromHat.ToButton e -> CODEC_HAT_TO_BUTTON.encode(e, ops, prefix); - case MappingEntry.FromHat.ToAxis e -> CODEC_HAT_TO_AXIS.encode(e, ops, prefix); - case MappingEntry.FromHat.ToHat e -> CODEC_HAT_TO_HAT.encode(e, ops, prefix); - case MappingEntry.FromNothing.ToButton e -> CODEC_NOTHING_TO_BUTTON.encode(e, ops, prefix); - case MappingEntry.FromNothing.ToAxis e -> CODEC_NOTHING_TO_AXIS.encode(e, ops, prefix); - case MappingEntry.FromNothing.ToHat e -> CODEC_NOTHING_TO_HAT.encode(e, ops, prefix); - }; - } - - @Override - public DataResult> decode(DynamicOps ops, T input) { - // First decode the stub to determine types - return CODEC_STUB.parse(ops, input).flatMap(stub -> switch (stub) { - case MappingEntryStub s when s.inputType() == MapType.BUTTON && s.outputType() == MapType.BUTTON -> - CODEC_BUTTON_TO_BUTTON.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.BUTTON && s.outputType() == MapType.AXIS -> - CODEC_BUTTON_TO_AXIS.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.BUTTON && s.outputType() == MapType.HAT -> - CODEC_BUTTON_TO_HAT.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.AXIS && s.outputType() == MapType.BUTTON -> - CODEC_AXIS_TO_BUTTON.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.AXIS && s.outputType() == MapType.AXIS -> - CODEC_AXIS_TO_AXIS.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.AXIS && s.outputType() == MapType.HAT -> - CODEC_AXIS_TO_HAT.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.HAT && s.outputType() == MapType.BUTTON -> - CODEC_HAT_TO_BUTTON.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.HAT && s.outputType() == MapType.AXIS -> - CODEC_HAT_TO_AXIS.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.HAT && s.outputType() == MapType.HAT -> - CODEC_HAT_TO_HAT.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.NOTHING && s.outputType() == MapType.BUTTON -> - CODEC_NOTHING_TO_BUTTON.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.NOTHING && s.outputType() == MapType.AXIS -> - CODEC_NOTHING_TO_AXIS.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - case MappingEntryStub s when s.inputType() == MapType.NOTHING && s.outputType() == MapType.HAT -> - CODEC_NOTHING_TO_HAT.decode(ops, input) - .map(pair -> pair.mapFirst(e -> (MappingEntry) e)); - default -> DataResult.error(() -> "Unknown MappingEntry type for input type " + stub.inputType() + " and output type " + stub.outputType()); - }); - } - }; - - - private record MappingEntryStub(MapType inputType, MapType outputType) { - } - - private MappingEntryCodecs() { - } - -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntryTypeAdapter.java b/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntryTypeAdapter.java deleted file mode 100644 index 92f0f3a0e..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/mapping/MappingEntryTypeAdapter.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input.mapping; - -import com.google.gson.JsonDeserializationContext; -import com.google.gson.JsonDeserializer; -import com.google.gson.JsonElement; -import com.google.gson.JsonParseException; - -import java.lang.reflect.Type; - -public class MappingEntryTypeAdapter implements JsonDeserializer { - @Override - public MappingEntry deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { - InOutRecord inOut = context.deserialize(json, InOutRecord.class); - - Type type = switch (inOut.inputType()) { - case BUTTON -> switch (inOut.outputType()) { - case BUTTON -> MappingEntry.FromButton.ToButton.class; - case AXIS -> MappingEntry.FromButton.ToAxis.class; - case HAT -> MappingEntry.FromButton.ToHat.class; - case NOTHING -> throw new IllegalStateException(); - }; - case AXIS -> switch (inOut.outputType()) { - case BUTTON -> MappingEntry.FromAxis.ToButton.class; - case AXIS -> MappingEntry.FromAxis.ToAxis.class; - case HAT -> MappingEntry.FromAxis.ToHat.class; - case NOTHING -> throw new IllegalStateException(); - }; - case HAT -> switch (inOut.outputType()) { - case BUTTON -> MappingEntry.FromHat.ToButton.class; - case AXIS -> MappingEntry.FromHat.ToAxis.class; - case HAT -> MappingEntry.FromHat.ToHat.class; - case NOTHING -> throw new IllegalStateException(); - }; - case NOTHING -> switch (inOut.outputType()) { - case BUTTON -> MappingEntry.FromNothing.ToButton.class; - case AXIS -> MappingEntry.FromNothing.ToAxis.class; - case HAT -> MappingEntry.FromNothing.ToHat.class; - case NOTHING -> throw new IllegalStateException(); - }; - }; - - return context.deserialize(json, type); - } - - private record InOutRecord(MapType inputType, MapType outputType) { - } -} diff --git a/src/main/java/dev/isxander/controlify/controller/input/mapping/StateMapper.java b/src/main/java/dev/isxander/controlify/controller/input/mapping/StateMapper.java deleted file mode 100644 index 0ac303ff5..000000000 --- a/src/main/java/dev/isxander/controlify/controller/input/mapping/StateMapper.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.input.mapping; - -import dev.isxander.controlify.controller.input.ControllerState; - -public interface StateMapper { -ControllerState mapState(ControllerState state); -} diff --git a/src/main/java/dev/isxander/controlify/controller/rumble/RumbleComponent.java b/src/main/java/dev/isxander/controlify/controller/rumble/RumbleComponent.java deleted file mode 100644 index a79bfa7c6..000000000 --- a/src/main/java/dev/isxander/controlify/controller/rumble/RumbleComponent.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.rumble; - -import dev.isxander.controlify.config.settings.profile.RumbleSettings; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.rumble.RumbleManager; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; - -import java.util.Optional; - -public class RumbleComponent extends ECSComponentImpl { - public static final Identifier ID = CUtil.rl("rumble"); - - private RumbleState state = null; - private final RumbleManager rumbleManager; - - public RumbleComponent() { - this.rumbleManager = new RumbleManager(this); - } - - public void queueRumble(RumbleState state) { - if (settings().enabled) { - this.state = state; - } - } - - public Optional consumeRumble() { - RumbleState state = this.state; - this.state = null; - return Optional.ofNullable(state); - } - - public RumbleManager rumbleManager() { - return this.rumbleManager; - } - - public RumbleSettings settings() { - return this.controller().settings().rumble; - } - - public RumbleSettings defaultSettings() { - return this.controller().defaultSettings().rumble; - } - - @Override - public Identifier id() { - return ID; - } - -} diff --git a/src/main/java/dev/isxander/controlify/controller/steamdeck/SteamDeckComponent.java b/src/main/java/dev/isxander/controlify/controller/steamdeck/SteamDeckComponent.java deleted file mode 100644 index 849deadba..000000000 --- a/src/main/java/dev/isxander/controlify/controller/steamdeck/SteamDeckComponent.java +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controller.steamdeck; - -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.resources.Identifier; - -public class SteamDeckComponent extends ECSComponentImpl { - public static final Identifier ID = CUtil.rl("steam_deck"); - - @Override - public Identifier id() { - return ID; - } -} diff --git a/src/main/java/dev/isxander/controlify/controllermanager/AbstractControllerManager.java b/src/main/java/dev/isxander/controlify/controllermanager/AbstractControllerManager.java deleted file mode 100644 index 3f9c26ffe..000000000 --- a/src/main/java/dev/isxander/controlify/controllermanager/AbstractControllerManager.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controllermanager; - -import com.google.common.collect.ImmutableList; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.event.ControlifyEvents; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.driver.steamdeck.SteamDeckUtil; -import dev.isxander.controlify.hid.ControllerHIDInfo; -import dev.isxander.controlify.hid.HIDDevice; -import dev.isxander.controlify.hid.HIDID; -import dev.isxander.controlify.utils.ControllerUtils; -import dev.isxander.controlify.utils.log.ControlifyLogger; -import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; -import net.minecraft.CrashReport; -import net.minecraft.CrashReportCategory; -import net.minecraft.client.Minecraft; -import net.minecraft.server.packs.resources.ResourceProvider; - -import java.util.List; -import java.util.Map; -import java.util.Optional; - -public abstract class AbstractControllerManager implements ControllerManager { - protected final Controlify controlify; - protected final Minecraft minecraft; - - protected final Map controllersByJid = new Object2ObjectOpenHashMap<>(); - protected final Map controllersByUid = new Object2ObjectOpenHashMap<>(); - protected final Map hidInfoByUid = new Object2ObjectOpenHashMap<>(); - - protected final ControlifyLogger logger; - - public AbstractControllerManager(ControlifyLogger logger) { - this.controlify = Controlify.instance(); - this.minecraft = Minecraft.getInstance(); - this.logger = logger.createSubLogger("ControllerManager"); - } - - public Optional tryCreate(UniqueControllerID ucid, ControllerHIDInfo hidInfo) { - ControlifyLogger controllerLogger = logger.createSubLogger("Controller #" + ucid); - - try { - if (controllersByJid.containsKey(ucid)) { - controllerLogger.warn("Tried to create controller that already is initialised: {}.", ucid); - return Optional.empty(); - } - - if (hidInfo.type().dontLoad()) { - controllerLogger.debugLog("Preventing load of controller #" + ucid + " because its type prevents loading."); - return Optional.empty(); - } - - if (hidInfo.type().isSteamDeck() - && SteamDeckUtil.DECK_MODE.isDesktopMode() - ) { - controllerLogger.log("Preventing load of controller #{} because Steam Deck is in desktop mode.", ucid); - return Optional.empty(); - } - - return createController(ucid, hidInfo, controllerLogger); - } catch (Throwable e) { - controllerLogger.error("Failed to create controller #{}!", e, ucid); - CrashReport crashReport = CrashReport.forThrowable(e, "Creating controller #" + ucid); - CrashReportCategory category = crashReport.addCategory("Controller Info"); - category.setDetail("Unique controller ID", ucid); - category.setDetail("Controller identification", hidInfo.type()); - category.setDetail("HID path", hidInfo.hidDevice().map(HIDDevice::path).orElse("N/A")); - category.setDetail("System name", Optional.ofNullable(getControllerSystemName(ucid)).orElse("N/A")); - controllerLogger.crashReport(crashReport); - return Optional.empty(); - //throw new ReportedException(crashReport); - } - } - - protected abstract Optional createController(UniqueControllerID ucid, ControllerHIDInfo hidInfo, ControlifyLogger controllerLogger); - - @Override - public void tick(boolean outOfFocus) { - for (ControllerEntity controller : controllersByUid.values()) { - controller.update(outOfFocus); - ControlifyEvents.CONTROLLER_STATE_UPDATE.invoke(new ControlifyEvents.ControllerStateUpdate(controller)); - } - } - - protected void onControllerConnected(ControllerEntity controller, boolean hotplug) { - logger.log("Controller connected: {}", ControllerUtils.createControllerString(controller)); - - ControlifyEvents.CONTROLLER_CONNECTED.invoke(new ControlifyEvents.ControllerConnected(controller, hotplug, false)); - } - - protected void onControllerRemoved(ControllerEntity controller) { - logger.log("Controller disconnected: {}", ControllerUtils.createControllerString(controller)); - - closeController(controller.uid()); - - ControlifyEvents.CONTROLLER_DISCONNECTED.invoke(new ControlifyEvents.ControllerDisconnected(controller)); - } - - @Override - public Optional reinitController(ControllerEntity controller, ControllerHIDInfo hidInfo) { - onControllerRemoved(controller); - - Optional newController = tryCreate(controller.info().ucid(), hidInfo); - newController.ifPresent(c -> { - ControllerUtils.wrapControllerError(() -> onControllerConnected(c, true), "Connecting controller", c); - }); - return newController; - } - - protected void addController(UniqueControllerID ucid, ControllerEntity controller) { - controllersByUid.put(controller.uid(), controller); - controllersByJid.put(ucid, controller); - } - - @Override - public void closeController(String uid) { - ControllerEntity controller = controllersByUid.remove(uid); - - if (controller == null) return; - - controller.close(); - - controllersByJid.remove(controller.info().ucid()); - hidInfoByUid.remove(uid); - } - - @Override - public List getConnectedControllers() { - return ImmutableList.copyOf(controllersByUid.values()); - } - - @Override - public boolean isControllerConnected(String uid) { - return controllersByUid.containsKey(uid); - } - - protected int getControllerCountWithMatchingHID(HIDID hid) { - return (int) controllersByJid.values().stream() - .filter(c -> c.info().hid().isPresent() && c.info().hid().get().hidid().equals(hid)) - .count(); - } - - @Override - public void close() { - controllersByUid.values().forEach(ControllerEntity::close); - } - - protected abstract void loadGamepadMappings(ResourceProvider resourceProvider); - - protected abstract String getControllerSystemName(UniqueControllerID ucid); -} diff --git a/src/main/java/dev/isxander/controlify/controllermanager/ControllerManager.java b/src/main/java/dev/isxander/controlify/controllermanager/ControllerManager.java deleted file mode 100644 index 9f7cb531a..000000000 --- a/src/main/java/dev/isxander/controlify/controllermanager/ControllerManager.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controllermanager; - -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.hid.ControllerHIDInfo; - -import java.util.List; -import java.util.Optional; - -public interface ControllerManager { - void discoverControllers(); - - void tick(boolean outOfFocus); - - boolean probeConnectedControllers(); - - List getConnectedControllers(); - - boolean isControllerConnected(String uid); - - boolean isControllerGamepad(UniqueControllerID ucid); - - Optional reinitController(ControllerEntity controller, ControllerHIDInfo hidInfo); - - void closeController(String uid); - - void close(); -} diff --git a/src/main/java/dev/isxander/controlify/controllermanager/SDLControllerManager.java b/src/main/java/dev/isxander/controlify/controllermanager/SDLControllerManager.java deleted file mode 100644 index 7e10dd3d3..000000000 --- a/src/main/java/dev/isxander/controlify/controllermanager/SDLControllerManager.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.controllermanager; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.controller.info.ControllerInfo; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.debug.DebugProperties; -import dev.isxander.controlify.driver.CompoundDriver; -import dev.isxander.controlify.driver.Driver; -import dev.isxander.controlify.driver.sdl.SDL3GamepadDriver; -import dev.isxander.controlify.driver.sdl.SDL3JoystickDriver; -import dev.isxander.controlify.driver.sdl.SDLUtil; -import dev.isxander.controlify.driver.steamdeck.SteamDeckDriver; -import dev.isxander.controlify.driver.steamdeck.SteamDeckUtil; -import dev.isxander.controlify.hid.ControllerHIDInfo; -import dev.isxander.controlify.hid.HIDDevice; -import dev.isxander.controlify.hid.HIDID; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.ControllerUtils; -import dev.isxander.controlify.utils.log.ControlifyLogger; -import dev.isxander.sdl.*; -import net.minecraft.server.packs.resources.Resource; -import net.minecraft.server.packs.resources.ResourceProvider; -import org.jetbrains.annotations.NotNull; - -import java.io.InputStream; -import java.nio.ByteBuffer; -import java.util.*; -import java.util.stream.Collectors; - -import static dev.isxander.sdl.SdlEvents.*; - -public class SDLControllerManager extends AbstractControllerManager { - - private final Sdl sdl; - - private SdlEvent event = new SdlEvent(); - - private boolean steamDeckConsumed = false; - - public SDLControllerManager(Sdl sdl, ControlifyLogger logger) { - super(logger); - this.sdl = sdl; - logger.debugLog("Controller manager using SDL3"); - - this.loadGamepadMappings(minecraft.getResourceManager()); - } - - @Override - public void tick(boolean outOfFocus) { - if (event == null) { - logger.warn("SDL_Event has somehow been set to null. Recreating..."); - event = new SdlEvent(); - } - - while (sdl.events().SDL_PollEvent(event)) { - switch (event.type()) { - // On added, `which` refers to the device index - case SDL_EVENT_JOYSTICK_ADDED -> { - var jdevice = (SdlEvent.JoyDevice) event.data(); - SdlJoystickId jid = jdevice.which(); - logger.validateIsTrue(jid != null, "event.jdevice.which was null during SDL_EVENT_JOYSTICK_ADDED event"); - - logger.debugLog("SDL event: Joystick added: {}", jid.value()); - - UniqueControllerID ucid = new SDLUniqueControllerID(jid); - - Optional controllerOpt = tryCreate( - ucid, - fetchTypeFromSDL(sdl, jid) - .orElse(new ControllerHIDInfo(ControllerType.DEFAULT, Optional.empty())) - ); - controllerOpt.ifPresent(controller -> { - ControllerUtils.wrapControllerError(() -> onControllerConnected(controller, true), "Connecting controller", controller); - }); - } - - // On removed, `which` refers to the device instance ID - case SDL_EVENT_JOYSTICK_REMOVED -> { - var jdevice = (SdlEvent.JoyDevice) event.data(); - SdlJoystickId jid = jdevice.which(); - logger.validateIsTrue(jid != null, "event.jdevice.which was null during SDL_EVENT_JOYSTICK_REMOVED event"); - - logger.debugLog("SDL event: Joystick removed: {}", jid.value()); - - getController(new SDLUniqueControllerID(jid)) - .ifPresentOrElse( - this::onControllerRemoved, - () -> CUtil.LOGGER.warn("Controller removed but not found: {}", jid.value()) - ); - } - } - } - - super.tick(outOfFocus); - } - - @Override - public void discoverControllers() { - logger.debugLog("Discovering controllers..."); - - SdlJoystickId[] joysticks = sdl.joystick().SDL_GetJoysticks(); - for (SdlJoystickId jid : joysticks) { - Optional controllerOpt = tryCreate( - new SDLUniqueControllerID(jid), - fetchTypeFromSDL(sdl, jid) - .orElse(new ControllerHIDInfo(ControllerType.DEFAULT, Optional.empty())) - ); - controllerOpt.ifPresent(controller -> onControllerConnected(controller, false)); - } - } - - @Override - protected Optional createController(UniqueControllerID ucid, ControllerHIDInfo hidInfo, ControlifyLogger controllerLogger) { - SdlJoystickId jid = ((SDLUniqueControllerID) ucid).jid(); - controllerLogger.debugLog("Creating controller: {}", jid.value()); - - boolean isGamepad = isControllerGamepad(ucid) && !DebugProperties.FORCE_JOYSTICK; - controllerLogger.debugLog("Controller is gamepad: {}", isGamepad); - - List drivers = new ArrayList<>(); - if ((SteamDeckUtil.DECK_MODE.isGamingMode() || DebugProperties.STEAM_DECK_CUSTOM_CEF_URL != null) - && !steamDeckConsumed - && hidInfo.type().namespace().equals(SteamDeckUtil.STEAM_DECK_NAMESPACE) - ) { - controllerLogger.debugLog("Controller is steam deck candidate"); - Optional steamDeckDriver = SteamDeckDriver.create(controllerLogger); - if (steamDeckDriver.isPresent()) { - drivers.add(steamDeckDriver.get()); - steamDeckConsumed = true; - controllerLogger.debugLog("Adding SteamDeckDriver - this controller has been reserved for Steam Deck"); - } - } - - if (isGamepad) { - SdlGamepadHandle ptrGamepad = SDLUtil.openGamepad(sdl, jid); - drivers.add(new SDL3GamepadDriver(sdl, ptrGamepad, jid, hidInfo.type(), controllerLogger)); - } else { - SdlJoystickHandle ptrJoystick = SDLUtil.openJoystick(sdl, jid); - drivers.add(new SDL3JoystickDriver(sdl, ptrJoystick, jid, hidInfo.type(), controllerLogger)); - } - - controllerLogger.debugLog("Drivers: {}", drivers.stream().map(driver -> driver.getClass().getSimpleName()).collect(Collectors.joining(", "))); - - CompoundDriver compoundDriver = new CompoundDriver(drivers); - - ControllerInfo info = new ControllerInfo(ucid, hidInfo.type(), hidInfo.hidDevice()); - ControllerEntity controller = new ControllerEntity( - info, - compoundDriver, - this.controlify.config().getActiveProfile(), - ProfileSettings.createDefault(), - controllerLogger - ); - - controllerLogger.debugLog("Unique Controller ID: {}", info.ucid()); - - this.addController(ucid, controller); - return Optional.of(controller); - } - - @Override - public boolean probeConnectedControllers() { - return sdl.joystick().SDL_HasJoystick() || sdl.gamepad().SDL_HasGamepad(); - } - - @Override - public boolean isControllerGamepad(UniqueControllerID ucid) { - SdlJoystickId jid = ((SDLUniqueControllerID) ucid).jid; - return sdl.gamepad().SDL_IsGamepad(jid); - } - - @Override - protected String getControllerSystemName(UniqueControllerID ucid) { - SdlJoystickId jid = ((SDLUniqueControllerID) ucid).jid; - return isControllerGamepad(ucid) - ? sdl.gamepad().SDL_GetGamepadNameForID(jid) - : sdl.joystick().SDL_GetJoystickNameForID(jid); - } - - private Optional getController(UniqueControllerID ucid) { - return Optional.ofNullable(controllersByJid.getOrDefault(ucid, null)); - } - - @Override - protected void loadGamepadMappings(ResourceProvider resourceProvider) { - CUtil.LOGGER.debugLog("Loading gamepad mappings..."); - - Optional resourceOpt = resourceProvider - .getResource(CUtil.rl("controllers/gamecontrollerdb-sdl3.txt")); - if (resourceOpt.isEmpty()) { - CUtil.LOGGER.error("Failed to find game controller database."); - return; - } - - try (InputStream is = resourceOpt.get().open()) { - byte[] bytes = is.readAllBytes(); - ByteBuffer byteBuffer = ByteBuffer.allocateDirect(bytes.length); - byteBuffer.put(bytes); - byteBuffer.flip(); - - SdlIoStreamHandle stream = sdl.ioStream().SDL_IOFromConstMem(byteBuffer); - if (stream == null) throw new IllegalStateException("Failed to open stream"); - - int count = sdl.gamepad().SDL_AddGamepadMappingsFromIO(stream, true); - if (count < 0) { - CUtil.LOGGER.error("Failed to load gamepad mappings: {}", sdl.error().SDL_GetError()); - } else if (count == 0) { - CUtil.LOGGER.warn("Successfully applied gamepad mappings but none were found for this OS. Unsupported OS?"); - } else { - CUtil.LOGGER.log("Successfully loaded {} gamepad mapping entries!", count); - } - } catch (Throwable e) { - CUtil.LOGGER.error("Failed to load gamepad mappings", e); - } - } - - private static Optional fetchTypeFromSDL(Sdl sdl, SdlJoystickId jid) { - int vid = sdl.joystick().SDL_GetJoystickVendorForID(jid); - int pid = sdl.joystick().SDL_GetJoystickProductForID(jid); - SdlGuid guid = sdl.joystick().SDL_GetJoystickGUIDForID(jid); - String guidStr = guid.toString(); - - if (vid != 0 && pid != 0) { - CUtil.LOGGER.log("Using SDL to identify controller type."); - return Optional.of(new ControllerHIDInfo( - Controlify.instance().controllerTypeManager().getControllerType(new HIDID(vid, pid)), - Optional.of(new HIDDevice(new HIDID(vid, pid), guidStr)) - )); - } - - return Optional.empty(); - } - - public record SDLUniqueControllerID(@NotNull SdlJoystickId jid) implements UniqueControllerID { - @Override - public boolean equals(Object obj) { - return obj instanceof SDLUniqueControllerID && ((SDLUniqueControllerID) obj).jid.equals(jid); - } - - @Override - public String toString() { - return "SDL-" + jid.value(); - } - - @Override - public int hashCode() { - return Objects.hash(jid.value()); - } - } -} diff --git a/src/main/java/dev/isxander/controlify/driver/Driver.java b/src/main/java/dev/isxander/controlify/driver/Driver.java deleted file mode 100644 index e258310f3..000000000 --- a/src/main/java/dev/isxander/controlify/driver/Driver.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.driver; - -import dev.isxander.controlify.controller.ControllerEntity; - -public interface Driver { - default void addComponents(ControllerEntity controller) {} - - default void update(ControllerEntity controller, boolean outOfFocus) {} - - default void close() {} -} diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDL3GamepadDriver.java b/src/main/java/dev/isxander/controlify/driver/sdl/SDL3GamepadDriver.java deleted file mode 100644 index d9f981cfb..000000000 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDL3GamepadDriver.java +++ /dev/null @@ -1,277 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.driver.sdl; - -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.controller.touchpad.TouchpadComponent; -import dev.isxander.controlify.controller.gyro.GyroComponent; -import dev.isxander.controlify.controller.input.GamepadInputs; -import dev.isxander.controlify.controller.gyro.GyroState; -import dev.isxander.controlify.controller.*; -import dev.isxander.controlify.controller.impl.ControllerStateImpl; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.controller.touchpad.Touchpads; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.log.ControlifyLogger; -import dev.isxander.sdl.*; -import org.joml.Vector2f; - -import java.lang.foreign.MemorySegment; -import java.lang.foreign.ValueLayout; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.FloatBuffer; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.IntStream; - -import static dev.isxander.controlify.utils.CUtil.*; -import static dev.isxander.sdl.SdlGamepad.*; - -public class SDL3GamepadDriver extends SDLCommonDriver { - - private static final int GYRO_AXIS_COUNT = 3; - private static final List INPUT_MAPPINGS = List.of( - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.LEFT_STICK_AXIS_RIGHT, SDL_GAMEPAD_AXIS_LEFTX, true), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.LEFT_STICK_AXIS_LEFT, SDL_GAMEPAD_AXIS_LEFTX, false), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.LEFT_STICK_AXIS_DOWN, SDL_GAMEPAD_AXIS_LEFTY, true), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.LEFT_STICK_AXIS_UP, SDL_GAMEPAD_AXIS_LEFTY, false), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.RIGHT_STICK_AXIS_RIGHT, SDL_GAMEPAD_AXIS_RIGHTX, true), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.RIGHT_STICK_AXIS_LEFT, SDL_GAMEPAD_AXIS_RIGHTX, false), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.RIGHT_STICK_AXIS_DOWN, SDL_GAMEPAD_AXIS_RIGHTY, true), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.RIGHT_STICK_AXIS_UP, SDL_GAMEPAD_AXIS_RIGHTY, false), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.LEFT_TRIGGER_AXIS, SDL_GAMEPAD_AXIS_LEFT_TRIGGER, true), - new SDLGamepadInputMapping.AxisMapping(GamepadInputs.RIGHT_TRIGGER_AXIS, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, true), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.SOUTH_BUTTON, SDL_GAMEPAD_BUTTON_SOUTH), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.EAST_BUTTON, SDL_GAMEPAD_BUTTON_EAST), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.WEST_BUTTON, SDL_GAMEPAD_BUTTON_WEST), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.NORTH_BUTTON, SDL_GAMEPAD_BUTTON_NORTH), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.LEFT_SHOULDER_BUTTON, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.RIGHT_SHOULDER_BUTTON, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.BACK_BUTTON, SDL_GAMEPAD_BUTTON_BACK), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.START_BUTTON, SDL_GAMEPAD_BUTTON_START), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.GUIDE_BUTTON, SDL_GAMEPAD_BUTTON_GUIDE), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.DPAD_UP_BUTTON, SDL_GAMEPAD_BUTTON_DPAD_UP), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.DPAD_DOWN_BUTTON, SDL_GAMEPAD_BUTTON_DPAD_DOWN), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.DPAD_LEFT_BUTTON, SDL_GAMEPAD_BUTTON_DPAD_LEFT), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.DPAD_RIGHT_BUTTON, SDL_GAMEPAD_BUTTON_DPAD_RIGHT), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.LEFT_STICK_BUTTON, SDL_GAMEPAD_BUTTON_LEFT_STICK), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.RIGHT_STICK_BUTTON, SDL_GAMEPAD_BUTTON_RIGHT_STICK), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.MISC_1_BUTTON, SDL_GAMEPAD_BUTTON_MISC1), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.MISC_2_BUTTON, SDL_GAMEPAD_BUTTON_MISC2), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.MISC_3_BUTTON, SDL_GAMEPAD_BUTTON_MISC3), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.MISC_4_BUTTON, SDL_GAMEPAD_BUTTON_MISC4), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.MISC_5_BUTTON, SDL_GAMEPAD_BUTTON_MISC5), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.MISC_6_BUTTON, SDL_GAMEPAD_BUTTON_MISC6), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.LEFT_PADDLE_1_BUTTON, SDL_GAMEPAD_BUTTON_LEFT_PADDLE1), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.LEFT_PADDLE_2_BUTTON, SDL_GAMEPAD_BUTTON_LEFT_PADDLE2), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.RIGHT_PADDLE_1_BUTTON, SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.RIGHT_PADDLE_2_BUTTON, SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2), - new SDLGamepadInputMapping.ButtonMapping(GamepadInputs.TOUCHPAD_1_BUTTON, SDL_GAMEPAD_BUTTON_TOUCHPAD) - ); - - private InputComponent inputComponent; - private GyroComponent gyroComponent; - private TouchpadComponent touchpadComponent; - - private final boolean isGryoSupported; - - private final int numTouchpads; - - private final MemorySegment gyroMemory; - private final FloatBuffer gyroBuffer; - - public SDL3GamepadDriver(Sdl sdl, SdlGamepadHandle ptrController, SdlJoystickId jid, ControllerType type, ControlifyLogger logger) { - super(sdl, ptrController, jid, type, logger); - - this.isGryoSupported = sdl.gamepad().SDL_GamepadHasSensor(ptrController, SDL_SENSOR_GYRO); - this.numTouchpads = sdl.gamepad().SDL_GetNumGamepadTouchpads(ptrController); - - if (this.isGryoSupported) { - sdl.gamepad().SDL_SetGamepadSensorEnabled(ptrController, SDL_SENSOR_GYRO, true); - - this.gyroMemory = this.arena.allocate(ValueLayout.JAVA_FLOAT, GYRO_AXIS_COUNT); - } else { - this.gyroMemory = MemorySegment.NULL; - } - - this.gyroBuffer = this.gyroMemory.asByteBuffer() - .order(ByteOrder.nativeOrder()) - .asFloatBuffer(); - } - - @Override - public void addComponents(ControllerEntity controller) { - super.addComponents(controller); - - int buttonCount = (int) INPUT_MAPPINGS.stream() - .filter(m -> m instanceof SDLGamepadInputMapping.ButtonMapping) - .filter(m -> m.hasInput(sdl.gamepad(), ptrController)) - .count(); - int axisCount = (int) INPUT_MAPPINGS.stream() - .filter(m -> m instanceof SDLGamepadInputMapping.AxisMapping) - .filter(m -> m.hasInput(sdl.gamepad(), ptrController)) - .count(); - - controller.setComponent( - this.inputComponent = new InputComponent( - controller, - buttonCount, - axisCount, - 0, - true, - GamepadInputs.DEADZONE_GROUPS, - controller.info().type().mappingId() - ) - ); - - if (this.isGryoSupported) { - controller.setComponent(this.gyroComponent = new GyroComponent()); - } - - if (this.numTouchpads > 0) { - controller.setComponent(this.touchpadComponent = new TouchpadComponent( - new Touchpads( - IntStream.range(0, numTouchpads) - .mapToObj(i -> - new Touchpads.Touchpad( - sdl.gamepad().SDL_GetNumGamepadTouchpadFingers(ptrController, i) - ) - ).toArray(Touchpads.Touchpad[]::new) - ) - )); - } - } - - @Override - public void update(ControllerEntity controller, boolean outOfFocus) { - super.update(controller, outOfFocus); - - this.updateInput(); - this.updateGyro(); - this.updateTouchpad(); - } - - private void updateInput() { - ControllerStateImpl state = new ControllerStateImpl(); - - for (SDLGamepadInputMapping m : INPUT_MAPPINGS) { - m.applyState(sdl.gamepad(), ptrController, state); - } - - this.inputComponent.pushState(state); - } - - private void updateGyro() { - if (!isGryoSupported) return; - - if (sdl.gamepad().SDL_GetGamepadSensorData(ptrController, SDL_SENSOR_GYRO, gyroBuffer)) { - this.gyroComponent.setState( - new GyroState(gyroBuffer.get(0), gyroBuffer.get(1), gyroBuffer.get(2)) - ); - } else { - CUtil.LOGGER.error("Could not get gyro data: {}", sdl.error().SDL_GetError()); - } - } - - private void updateTouchpad() { - if (numTouchpads < 1) return; - - for (int touchpadIdx = 0; touchpadIdx < numTouchpads; touchpadIdx++) { - Touchpads.Touchpad touchpad = this.touchpadComponent.touchpads()[touchpadIdx]; - - List fingers = new ArrayList<>(); - for (int fingerIdx = 0; fingerIdx < touchpad.maxFingers(); fingerIdx++) { - var fingerState = new SdlRefs.ByteRef(); - var x = new SdlRefs.FloatRef(); - var y = new SdlRefs.FloatRef(); - var pressure = new SdlRefs.FloatRef(); - - if (!sdl.gamepad().SDL_GetGamepadTouchpadFinger(ptrController, touchpadIdx, fingerIdx, fingerState, x, y, pressure)) { - CUtil.LOGGER.error("Failed to fetch touchpad finger: {}", sdl.error().SDL_GetError()); - } else if (fingerState.value == 1) { - fingers.add( - new Touchpads.Finger( - fingerIdx, - // SDL already returns the correct range for touchpad position and pressure - new Vector2f(x.value, y.value), - pressure.value - ) - ); - } - } - - touchpad.pushFingers(fingers); - } - } - - @Override - protected SdlPropertiesId SDL_GetControllerProperties(SdlGamepadHandle ptrController) { - return sdl.gamepad().SDL_GetGamepadProperties(ptrController); - } - - @Override - protected String SDL_GetControllerName(SdlGamepadHandle ptrController) { - return sdl.gamepad().SDL_GetGamepadName(ptrController); - } - - @Override - protected SdlGuid SDL_GetControllerGUIDForID(SdlJoystickId jid) { - return sdl.gamepad().SDL_GetGamepadGUIDForID(jid); - } - - @Override - protected String SDL_GetControllerSerial(SdlGamepadHandle ptrController) { - return sdl.gamepad().SDL_GetGamepadSerial(ptrController); - } - - @Override - protected short SDL_GetControllerVendor(SdlGamepadHandle ptrController) { - return sdl.gamepad().SDL_GetGamepadVendor(ptrController); - } - - @Override - protected short SDL_GetControllerProduct(SdlGamepadHandle ptrController) { - return sdl.gamepad().SDL_GetGamepadProduct(ptrController); - } - - @Override - protected int SDL_GetControllerConnectionState(SdlGamepadHandle ptrController) { - return sdl.gamepad().SDL_GetGamepadConnectionState(ptrController); - } - - @Override - protected boolean SDL_CloseController(SdlGamepadHandle ptrController) { - sdl.gamepad().SDL_CloseGamepad(ptrController); - return true; - } - - @Override - protected boolean SDL_RumbleController(SdlGamepadHandle ptrController, float strong, float weak, int durationMs) { - return sdl.gamepad().SDL_RumbleGamepad(ptrController, (short) (strong * 0xFFFF), (short) (weak * 0xFFFF), durationMs); - } - - @Override - protected boolean SDL_RumbleControllerTriggers(SdlGamepadHandle ptrController, float left, float right, int durationMs) { - return sdl.gamepad().SDL_RumbleGamepadTriggers(ptrController, (short) (left * 0xFFFF), (short) (right * 0xFFFF), durationMs); - } - - @Override - protected int SDL_GetControllerPowerInfo(SdlGamepadHandle ptrController, SdlRefs.IntRef percent) { - return sdl.gamepad().SDL_GetGamepadPowerInfo(ptrController, percent); - } - - @Override - protected boolean SDL_SendControllerEffect(SdlGamepadHandle ptrController, ByteBuffer effect) { - return sdl.gamepad().SDL_SendGamepadEffect(ptrController, effect); - } - - @Override - protected boolean SDL_SetControllerLED(SdlGamepadHandle ptrController, byte red, byte green, byte blue) { - return sdl.gamepad().SDL_SetGamepadLED(ptrController, red, green, blue); - } -} diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDLGamepadInputMapping.java b/src/main/java/dev/isxander/controlify/driver/sdl/SDLGamepadInputMapping.java deleted file mode 100644 index e7dad49a1..000000000 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDLGamepadInputMapping.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.driver.sdl; - -import dev.isxander.controlify.controller.impl.ControllerStateImpl; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.sdl.SdlGamepad; -import dev.isxander.sdl.SdlGamepadHandle; -import net.minecraft.resources.Identifier; - -public sealed interface SDLGamepadInputMapping { - Identifier controlifyInput(); - - boolean hasInput(SdlGamepad sdl, SdlGamepadHandle gamepad); - - void applyState(SdlGamepad sdl, SdlGamepadHandle gamepad, ControllerStateImpl state); - - record ButtonMapping(Identifier controlifyInput, int sdlButton) implements SDLGamepadInputMapping { - @Override - public boolean hasInput(SdlGamepad sdl, SdlGamepadHandle gamepad) { - return sdl.SDL_GamepadHasButton(gamepad, sdlButton); - } - - @Override - public void applyState(SdlGamepad sdl, SdlGamepadHandle gamepad, ControllerStateImpl state) { - if (hasInput(sdl, gamepad)) { - state.setButton(controlifyInput, sdl.SDL_GetGamepadButton(gamepad, sdlButton)); - } - } - } - - record AxisMapping(Identifier controlifyInput, int sdlAxis, boolean positive) implements SDLGamepadInputMapping { - @Override - public boolean hasInput(SdlGamepad sdl, SdlGamepadHandle gamepad) { - return sdl.SDL_GamepadHasAxis(gamepad, sdlAxis); - } - - @Override - public void applyState(SdlGamepad sdl, SdlGamepadHandle gamepad, ControllerStateImpl state) { - if (hasInput(sdl, gamepad)) { - float f = CUtil.mapShortToFloat(sdl.SDL_GetGamepadAxis(gamepad, sdlAxis)); - f = positive ? CUtil.positiveAxis(f) : CUtil.negativeAxis(f); - - state.setAxis(controlifyInput, f); - } - } - } -} diff --git a/src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckDriver.java b/src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckDriver.java deleted file mode 100644 index 2c11f8cf6..000000000 --- a/src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckDriver.java +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.driver.steamdeck; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.battery.BatteryLevelComponent; -import dev.isxander.controlify.controller.battery.PowerState; -import dev.isxander.controlify.controller.gyro.GyroComponent; -import dev.isxander.controlify.controller.gyro.GyroState; -import dev.isxander.controlify.controller.impl.ControllerStateImpl; -import dev.isxander.controlify.controller.info.DriverNameComponent; -import dev.isxander.controlify.controller.info.GUIDComponent; -import dev.isxander.controlify.controller.info.UIDComponent; -import dev.isxander.controlify.controller.input.GamepadInputs; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.controller.steamdeck.SteamDeckComponent; -import dev.isxander.controlify.controller.touchpad.TouchpadComponent; -import dev.isxander.controlify.controller.touchpad.Touchpads; -import dev.isxander.controlify.driver.Driver; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.log.ControlifyLogger; -import dev.isxander.deckapi.api.ControllerButton; -import dev.isxander.deckapi.api.ControllerState; -import dev.isxander.deckapi.api.SteamDeck; -import net.minecraft.util.Mth; -import org.joml.Vector2f; - -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletionException; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; - -import static dev.isxander.controlify.utils.CUtil.*; - -public class SteamDeckDriver implements Driver { - private static SteamDeck deck; - private static boolean triedToLoad = false; - - private InputComponent inputComponent; - private GyroComponent gyroComponent; - private BatteryLevelComponent batteryLevelComponent; - private TouchpadComponent touchpadComponent; - - private final ControlifyLogger logger; - - private final AtomicBoolean runningPoller = new AtomicBoolean(false); - - public SteamDeckDriver(ControlifyLogger logger) { - this.logger = logger.createSubLogger("SteamDeckDriver"); - } - - @Override - public void addComponents(ControllerEntity controller) { - controller.setComponent(new DriverNameComponent("Steam Deck")); - controller.setComponent(new GUIDComponent("steamdeck")); - controller.setComponent(new UIDComponent(CUtil.createUIDFromBytes("steamdeck".getBytes()))); - - controller.setComponent( - this.inputComponent = new InputComponent( - controller, - 20, - 10, - 0, - true, - GamepadInputs.DEADZONE_GROUPS, - controller.info().type().mappingId() - ) - ); - - controller.setComponent( - this.gyroComponent = new GyroComponent() - ); - - // don't add - deck reports incorrect values - this.batteryLevelComponent = new BatteryLevelComponent(); - - controller.setComponent( - this.touchpadComponent = new TouchpadComponent( - new Touchpads( - new Touchpads.Touchpad[]{ - new Touchpads.Touchpad(1), // left - new Touchpads.Touchpad(1), // right - } - ) - ) // there are two touchpads, one for each thumb - ); - - controller.setComponent(new SteamDeckComponent()); - } - - private void ensurePolling() { - if (runningPoller.get()) return; - - logger.debugLog("Spinning up poller thread..."); - runningPoller.set(true); - new Thread(() -> { - int failureScore = 0; - while (runningPoller.get() && failureScore < 50) { - try { - deck.poll() - .orTimeout(1000, java.util.concurrent.TimeUnit.MILLISECONDS) - .join(); // join into thread so we don't spam the CEF before it's returned a value - } catch (Throwable e) { - if (e instanceof CompletionException && e.getCause() instanceof TimeoutException) { - // this might be due to the deck going into sleep mode (suspending) - // we ignore, and wait a bit before trying again to avoid useless cpu usage - logger.debugWarn("Encountered timeout exception while polling deck, assuming deck has suspended and waiting..."); - CUtil.sleepChecked(500); - failureScore += 1; - } else { - logger.error("Encountered exception while polling deck", e); - failureScore += 10; - } - } - } - }, "Steam Deck Poller").start(); - } - - @Override - public void update(ControllerEntity controller, boolean outOfFocus) { - ensurePolling(); - - ControllerStateImpl state = new ControllerStateImpl(); - ControllerState deckState = deck.getControllerState(); - - boolean focused = deck.isGameInFocus(); - - state.setButton(GamepadInputs.NORTH_BUTTON, deckState.getButtonState(ControllerButton.Y) && focused); - state.setButton(GamepadInputs.EAST_BUTTON, deckState.getButtonState(ControllerButton.B) && focused); - state.setButton(GamepadInputs.SOUTH_BUTTON, deckState.getButtonState(ControllerButton.A) && focused); - state.setButton(GamepadInputs.WEST_BUTTON, deckState.getButtonState(ControllerButton.X) && focused); - state.setButton(GamepadInputs.LEFT_SHOULDER_BUTTON, deckState.getButtonState(ControllerButton.L1) && focused); - state.setButton(GamepadInputs.RIGHT_SHOULDER_BUTTON, deckState.getButtonState(ControllerButton.R1) && focused); - state.setButton(GamepadInputs.START_BUTTON, deckState.getButtonState(ControllerButton.START) && focused); - state.setButton(GamepadInputs.BACK_BUTTON, deckState.getButtonState(ControllerButton.SELECT) && focused); - - // not sure if this should ever be used, since it will always open the steam menu -// state.setButton(GamepadInputs.GUIDE_BUTTON, deckState.getButtonState(ControllerButton.STEAM_HOME) && focused); - - state.setButton(GamepadInputs.LEFT_STICK_BUTTON, deckState.getButtonState(ControllerButton.L3) && focused); - state.setButton(GamepadInputs.RIGHT_STICK_BUTTON, deckState.getButtonState(ControllerButton.R3) && focused); - state.setButton(GamepadInputs.DPAD_UP_BUTTON, deckState.getButtonState(ControllerButton.DPAD_UP) && focused); - state.setButton(GamepadInputs.DPAD_DOWN_BUTTON, deckState.getButtonState(ControllerButton.DPAD_DOWN) && focused); - state.setButton(GamepadInputs.DPAD_LEFT_BUTTON, deckState.getButtonState(ControllerButton.DPAD_LEFT) && focused); - state.setButton(GamepadInputs.DPAD_RIGHT_BUTTON, deckState.getButtonState(ControllerButton.DPAD_RIGHT) && focused); - state.setButton(GamepadInputs.LEFT_PADDLE_1_BUTTON, deckState.getButtonState(ControllerButton.L4) && focused); - state.setButton(GamepadInputs.LEFT_PADDLE_2_BUTTON, deckState.getButtonState(ControllerButton.L5) && focused); - state.setButton(GamepadInputs.RIGHT_PADDLE_1_BUTTON, deckState.getButtonState(ControllerButton.R4) && focused); - state.setButton(GamepadInputs.RIGHT_PADDLE_2_BUTTON, deckState.getButtonState(ControllerButton.R5) && focused); - state.setButton(GamepadInputs.TOUCHPAD_1_BUTTON, deckState.getButtonState(ControllerButton.LEFT_TOUCHPAD_CLICK) && focused); - state.setButton(GamepadInputs.TOUCHPAD_2_BUTTON, deckState.getButtonState(ControllerButton.RIGHT_TOUCHPAD_CLICK) && focused); - state.setAxis(GamepadInputs.LEFT_STICK_AXIS_UP, zeroUnless(positiveAxis(mapShortToFloat(deckState.sLeftStickY())), focused)); - state.setAxis(GamepadInputs.LEFT_STICK_AXIS_DOWN, zeroUnless(negativeAxis(mapShortToFloat(deckState.sLeftStickY())), focused)); - state.setAxis(GamepadInputs.LEFT_STICK_AXIS_LEFT, zeroUnless(negativeAxis(mapShortToFloat(deckState.sLeftStickX())), focused)); - state.setAxis(GamepadInputs.LEFT_STICK_AXIS_RIGHT, zeroUnless(positiveAxis(mapShortToFloat(deckState.sLeftStickX())), focused)); - state.setAxis(GamepadInputs.RIGHT_STICK_AXIS_UP, zeroUnless(positiveAxis(mapShortToFloat(deckState.sRightStickY())), focused)); - state.setAxis(GamepadInputs.RIGHT_STICK_AXIS_DOWN, zeroUnless(negativeAxis(mapShortToFloat(deckState.sRightStickY())), focused)); - state.setAxis(GamepadInputs.RIGHT_STICK_AXIS_LEFT, zeroUnless(negativeAxis(mapShortToFloat(deckState.sRightStickX())), focused)); - state.setAxis(GamepadInputs.RIGHT_STICK_AXIS_RIGHT, zeroUnless(positiveAxis(mapShortToFloat(deckState.sRightStickX())), focused)); - state.setAxis(GamepadInputs.LEFT_TRIGGER_AXIS, zeroUnless(mapShortToFloat(deckState.sTriggerL()), focused)); - state.setAxis(GamepadInputs.RIGHT_TRIGGER_AXIS, zeroUnless(mapShortToFloat(deckState.sTriggerR()), focused)); - - this.inputComponent.pushState(state); - - this.gyroComponent.setState( - new GyroState( - deckState.flSoftwareGyroDegreesPerSecondPitch(), - -deckState.flSoftwareGyroDegreesPerSecondYaw(), - -deckState.flSoftwareGyroDegreesPerSecondRoll() - ).mul(Mth.DEG_TO_RAD) // we need radians per second, not degrees - ); - - this.batteryLevelComponent.setBatteryLevel( - new PowerState.Depleting((int) (mapShortToFloat(deckState.sBatteryLevel()) * 100)) - ); - - updateTouchpad( - this.touchpadComponent.touchpads()[0], - deckState.sLeftPadX(), - deckState.sLeftPadY(), - deckState.sPressurePadLeft(), - deckState.getButtonState(ControllerButton.LEFT_TOUCHPAD_TOUCH) - ); - updateTouchpad( - this.touchpadComponent.touchpads()[1], - deckState.sRightPadX(), - deckState.sRightPadY(), - deckState.sPressurePadRight(), - deckState.getButtonState(ControllerButton.RIGHT_TOUCHPAD_TOUCH) - ); - } - - private void updateTouchpad(Touchpads.Touchpad touchpad, short x, short y, short pressure, boolean touching) { - if (touching) { - // steam deck touchpad is -32768 to 32767, we need to map it to 0 to 1 - // origin [0,0] is middle and [1,1] is bottom right, we need to map it to top left - float mappedX = (mapShortToFloat(x) + 1) / 2f; - float mappedY = 1 - (mapShortToFloat(y) + 1) / 2f; - // pressure is still 0 up until actuation point (click) - float mappedPressure = mapShortToFloat(pressure); // [0, 1] - - touchpad.pushFingers( - List.of( - new Touchpads.Finger(0, new Vector2f(mappedX, mappedY), mappedPressure) - ) - ); - } else { - touchpad.pushFingers(List.of()); - } - } - - @Override - public void close() { - logger.debugLog("Closing driver..."); - try { - runningPoller.set(false); - deck.close(); - } catch (Exception e) { - logger.error("Failed to close driver", e); - } - } - - public static Optional create(ControlifyLogger logger) { - if (triedToLoad || !Controlify.instance().config().getSettings().globalSettings().useEnhancedSteamDeckDriver) - return Optional.empty(); - - triedToLoad = true; - - try { - deck = SteamDeckUtil.getDeckInstance().orElseThrow(); - return Optional.of(new SteamDeckDriver(logger)); - } catch (Exception e) { - logger.error("Failed to create Steam Deck driver", e); - return Optional.empty(); - } - } - - public static Optional getDeck() { - return Optional.ofNullable(deck); - } - - private static float zeroUnless(float f, boolean condition) { - return condition ? f : 0; - } -} diff --git a/src/main/java/dev/isxander/controlify/font/BindingFontHelper.java b/src/main/java/dev/isxander/controlify/font/BindingFontHelper.java deleted file mode 100644 index 40d1e1eaf..000000000 --- a/src/main/java/dev/isxander/controlify/font/BindingFontHelper.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.font; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.utils.CUtil; -import net.minecraft.client.gui.Font; -import net.minecraft.network.chat.Component; -import net.minecraft.network.chat.FontDescription; -import net.minecraft.network.chat.FormattedText; -import net.minecraft.network.chat.Style; -import net.minecraft.resources.Identifier; -import net.minecraft.util.FormattedCharSequence; -import net.minecraft.util.StringDecomposer; -import org.apache.commons.lang3.mutable.MutableInt; - -public final class BindingFontHelper { - public static final Identifier WRAPPER_FONT = CUtil.rl("inputs"); - public static final String PLACEHOLDER_KEY = "controlify.placeholder"; - public static final String PLACEHOLDER_CONTROLLER_ACTIVE_KEY = "controlify.placeholder.controller_active"; - - public static Component bindingWithActiveFallback(Identifier binding, Component fallback) { - return Component.translatableWithFallback(PLACEHOLDER_CONTROLLER_ACTIVE_KEY, "%2$s", binding(binding), fallback); - } - - public static Component bindingWithActiveFallback(InputBinding binding, Component fallback) { - return bindingWithActiveFallback(binding.id(), fallback); - } - - public static Component bindingWithFallback(Identifier binding, Component fallback) { - return Component.translatableWithFallback("controlify.placeholder", "%2$s", binding(binding), fallback); - } - - public static Component bindingWithFallback(InputBinding binding, Component fallback) { - return bindingWithFallback(binding.id(), fallback); - } - - public static Component binding(Identifier binding) { - return Component.keybind(binding.toString()).withStyle(style -> style.withFont(new FontDescription.Resource(WRAPPER_FONT))); - } - - public static Component binding(InputBinding binding) { - return binding(binding.id()); - } - - public static int getComponentHeight(Font font, FormattedCharSequence text) { - MutableInt mutableInt = new MutableInt(); - text.accept((index, style, codePoint) -> { - mutableInt.setValue(Math.max(mutableInt.intValue(), getHeight(font, codePoint, style))); - return true; - }); - return mutableInt.intValue(); - } - - public static int getComponentHeight(Font font, FormattedText text) { - MutableInt mutableInt = new MutableInt(); - StringDecomposer.iterateFormatted(text, Style.EMPTY, (index, style, codePoint) -> { - mutableInt.setValue(Math.max(mutableInt.intValue(), getHeight(font, codePoint, style))); - return true; - }); - return mutableInt.intValue(); - } - - private static int getHeight(Font font, int codepoint, Style style) { - // i can't figure out how to get the height here - return 15; - } -} diff --git a/src/main/java/dev/isxander/controlify/gui/controllers/BindController.java b/src/main/java/dev/isxander/controlify/gui/controllers/BindController.java deleted file mode 100644 index fe076895f..000000000 --- a/src/main/java/dev/isxander/controlify/gui/controllers/BindController.java +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.gui.controllers; - -import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.bindings.input.*; -import dev.isxander.controlify.controller.*; -import dev.isxander.controlify.controller.input.ControllerStateView; -import dev.isxander.controlify.controller.input.HatState; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.gui.screen.BindConsumerScreen; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.yacl3.api.Controller; -import dev.isxander.yacl3.api.Option; -import dev.isxander.yacl3.api.utils.Dimension; -import dev.isxander.yacl3.gui.YACLScreen; -import dev.isxander.yacl3.gui.controllers.ControllerWidget; -import net.minecraft.ChatFormatting; -import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.input.KeyEvent; -import net.minecraft.client.input.MouseButtonEvent; -import net.minecraft.network.chat.Component; -import net.minecraft.resources.Identifier; - -import java.util.Optional; - -public class BindController implements Controller { - private final Option option; - public final ControllerEntity controller; - private boolean conflicting; - - public BindController(Option option, ControllerEntity controller) { - this.option = option; - this.controller = controller; - } - - @Override - public Option option() { - return this.option; - } - - @Override - public Component formatValue() { - return Component.empty(); - } - - public void setConflicting(boolean conflicting) { - this.conflicting = conflicting; - } - - public boolean getConflicting() { - return this.conflicting; - } - - @Override - public BindControllerElement provideWidget(YACLScreen yaclScreen, Dimension dimension) { - return new BindControllerElement(this, yaclScreen, dimension); - } - - public static class BindControllerElement extends ControllerWidget implements ComponentProcessor { - public boolean awaitingControllerInput = false; - private final Component awaitingText = Component.translatable("controlify.gui.bind_input_awaiting").withStyle(ChatFormatting.ITALIC); - - public BindControllerElement(BindController control, YACLScreen screen, Dimension dim) { - super(control, screen, dim); - } - - @Override - protected void extractValueText(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { - if (awaitingControllerInput) { - graphics.text(textRenderer, awaitingText, getDimension().xLimit() - textRenderer.width(awaitingText) - getXPadding(), (int)(getDimension().centerY() - textRenderer.lineHeight / 2f), 0xFFFFFFFF, true); - } else { - var bind = control.option().pendingValue(); - if (EmptyInput.equals(bind)) return; - - Component text = Controlify.instance().inputFontMapper() - .getComponentFromBind(control.controller.info().type().namespace(), bind); - int width = textRenderer.width(text); - - graphics.text(textRenderer, text, getDimension().xLimit() - width - 1, (int)(getDimension().centerY() - textRenderer.lineHeight / 2f + 1), -1, false); - } - } - - @Override - public boolean keyPressed(KeyEvent keyEvent) { - if (isFocused() && keyEvent.key() == InputConstants.KEY_RETURN) { - openConsumerScreen(); - return true; - } - - return false; - } - - @Override - public boolean mouseClicked(MouseButtonEvent mouseButtonEvent, boolean doubleClick) { - if (getDimension().isPointInside((int) mouseButtonEvent.x(), (int) mouseButtonEvent.y())) { - openConsumerScreen(); - return true; - } - - return false; - } - - private void openConsumerScreen() { - awaitingControllerInput = true; - MinecraftUtil.setScreen(new BindConsumerScreen(this::getPressedBind, control.option(), this, MinecraftUtil.getScreen())); - } - - @Override - public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) { - if (controller != control.controller) return true; - - if (ControlifyBindings.GUI_PRESS.on(controller).justPressed()) { - openConsumerScreen(); - return true; - } - - return false; - } - - @Override - protected int getHoveredControlWidth() { - return getUnhoveredControlWidth(); - } - - @Override - protected int getUnhoveredControlWidth() { - if (awaitingControllerInput) - return textRenderer.width(awaitingText); - - Component text = Controlify.instance().inputFontMapper() - .getComponentFromBind(control.controller.info().type().namespace(), control.option().pendingValue()); - return textRenderer.width(text); - } - - @Override - protected int getValueColor() { - return control.conflicting ? 0xFFFF5555 : super.getValueColor(); - } - - public Optional getPressedBind() { - InputComponent input = control.controller.input().orElseThrow(); - ControllerStateView state = input.stateNow(); - ControllerStateView prevState = input.stateThen(); - - for (Identifier button : state.getButtons()) { - if (state.isButtonDown(button) && !prevState.isButtonDown(button)) { - return Optional.of(new ButtonInput(button)); - } - } - - for (Identifier axis : state.getAxes()) { - if (state.getAxisState(axis) > 0.5f && prevState.getAxisState(axis) <= 0.5f) { - return Optional.of(new AxisInput(axis)); - } - } - - for (Identifier hat : state.getHats()) { - HatState hatState = state.getHatState(hat); - if (hatState != HatState.CENTERED && prevState.getHatState(hat) == HatState.CENTERED) { - return Optional.of(new HatInput(hat, hatState)); - } - } - - return Optional.empty(); - } - } -} diff --git a/src/main/java/dev/isxander/controlify/gui/screen/BindConsumerScreen.java b/src/main/java/dev/isxander/controlify/gui/screen/BindConsumerScreen.java deleted file mode 100644 index b0b2700a5..000000000 --- a/src/main/java/dev/isxander/controlify/gui/screen/BindConsumerScreen.java +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.gui.screen; - -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.gui.controllers.BindController; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.yacl3.api.Option; -import dev.isxander.yacl3.api.utils.Dimension; -import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.input.KeyEvent; -import net.minecraft.client.input.MouseButtonEvent; -import net.minecraft.network.chat.Component; -import org.jspecify.annotations.NonNull; - -import java.util.Optional; - -public class BindConsumerScreen extends Screen implements ScreenProcessorProvider { - private final BindConsumer bindConsumer; - private final Option option; - private final Screen backgroundScreen; - private final BindController.BindControllerElement widgetToFocus; - private final ScreenProcessorImpl screenProcessor = new ScreenProcessorImpl(this); - - private int ticksTillClose; - private int ticksTillInput; - - public BindConsumerScreen(BindConsumer bindConsumer, Option option, BindController.BindControllerElement widgetToFocus, Screen backgroundScreen) { - super(Component.empty()); - this.bindConsumer = bindConsumer; - this.option = option; - this.widgetToFocus = widgetToFocus; - this.backgroundScreen = backgroundScreen; - this.ticksTillInput = 5; - } - - @Override - public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { - Dimension dim = widgetToFocus.getDimension(); - - backgroundScreen.extractRenderStateWithTooltipAndSubtitles(graphics, dim.centerX(), dim.centerY(), a); - - graphics.nextStratum(); - - // darken everything except the widget - graphics.fill(0, 0, width, dim.y() - 1, 0x80000000); - graphics.fill(0, dim.y(), dim.x() - 1, height, 0x80000000); - graphics.fill(dim.xLimit() + 1, dim.y() - 1, width, height, 0x80000000); - graphics.fill(dim.x(), dim.yLimit() + 1, dim.xLimit(), height, 0x80000000); - - super.extractRenderState(graphics, mouseX, mouseY, a); - } - - @Override - public void extractBackground(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { - // do not render background - } - - @Override - public void tick() { - if (ticksTillClose > 0) { - ticksTillClose--; - if (ticksTillClose == 0) { - widgetToFocus.awaitingControllerInput = false; - // don't call setScreen because will cause background to re-init - MinecraftUtil.forceSetScreen(backgroundScreen); - } - } - - if (ticksTillInput > 0) { - ticksTillInput--; - if (ticksTillInput > 0) { - return; - } - } - - // tick runs after all controller input ticks - - Optional pressedBind = bindConsumer.getPressedBind(); - if (pressedBind.isPresent()) { - option.requestSet(pressedBind.get()); - returnToBackground(); - } - } - - @Override - public boolean keyPressed(@NonNull KeyEvent keyEvent) { - boolean consumed = super.keyPressed(keyEvent); - if (consumed) return true; - - if (ticksTillInput > 0) return false; - returnToBackground(); - return true; - } - - @Override - public boolean mouseClicked(@NonNull MouseButtonEvent mouseButtonEvent, boolean doubleClick) { - boolean consumed = super.mouseClicked(mouseButtonEvent, doubleClick); - if (consumed) return true; - - if (ticksTillInput > 0) return false; - returnToBackground(); - return true; - } - - @Override - public boolean mouseDragged(@NonNull MouseButtonEvent mouseButtonEvent, double dx, double dy) { - boolean consumed = super.mouseDragged(mouseButtonEvent, dx, dy); - if (consumed) return true; - - if (ticksTillInput > 0) return false; - returnToBackground(); - return true; - } - - @Override - public boolean mouseScrolled(double mouseX, double mouseY, double amount, double d) { - boolean consumed = super.mouseScrolled(mouseX, mouseY, amount, d); - if (consumed) return true; - - if (ticksTillInput > 0) return false; - returnToBackground(); - return true; - } - - private void returnToBackground() { - ticksTillClose = 5; - } - - @Override - public ScreenProcessor screenProcessor() { - return screenProcessor; - } - - public interface BindConsumer { - Optional getPressedBind(); - } - - private static class ScreenProcessorImpl extends ScreenProcessor { - public ScreenProcessorImpl(BindConsumerScreen screen) { - super(screen); - } - - @Override - public void onControllerUpdate(ControllerEntity controller) { - // prevent all other controller input logic - } - } -} diff --git a/src/main/java/dev/isxander/controlify/impl/Controlify.java b/src/main/java/dev/isxander/controlify/impl/Controlify.java new file mode 100644 index 000000000..229e66e4c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/Controlify.java @@ -0,0 +1,680 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl; + +import com.mojang.blaze3d.Blaze3D; +import dev.isxander.controlify.api.ControlifyApi; +import dev.isxander.controlify.api.ControlifyEvents; +import dev.isxander.controlify.api.InputMode; +import dev.isxander.controlify.api.contextual.BuiltinContextualDomains; +import dev.isxander.controlify.impl.action.ActionLayerRegistryImpl; +import dev.isxander.controlify.impl.action.ActionSpecRegistryImpl; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.action.activator.ActivatorConfigManager; +import dev.isxander.controlify.impl.action.defaults.DefaultBindManager; +import dev.isxander.controlify.impl.action.proxy.ActionProxyManagerImpl; +import dev.isxander.controlify.impl.gui.radial.RadialIconManager; +import dev.isxander.controlify.compat.ControlifyCompat; +import dev.isxander.controlify.impl.config.ConfigManager; +import dev.isxander.controlify.impl.config.dto.profile.defaults.DefaultConfigManager; +import dev.isxander.controlify.impl.contextual.ContextualDomains; +import dev.isxander.controlify.impl.contextual.RuleSetManager; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.dualsense.TriggerEffectManager; +import dev.isxander.controlify.impl.controller.rumble.RumbleComponentImpl; +import dev.isxander.controlify.impl.controller.rumble.RumbleManager; +import dev.isxander.controlify.impl.controller.rumble.RumbleSourceManager; +import dev.isxander.controlify.impl.controllermanager.ControllerCoordinator; +import dev.isxander.controlify.impl.controllermanager.ControllerManager; +import dev.isxander.controlify.impl.controllermanager.ControllerTypeManager; +import dev.isxander.controlify.impl.debug.DebugProperties; +import dev.isxander.controlify.impl.driver.steamdeck.SteamDeckMode; +import dev.isxander.controlify.impl.driver.steamdeck.SteamDeckUtil; +import dev.isxander.controlify.impl.font.InputFontMapper; +import dev.isxander.controlify.impl.gui.guide.InGameButtonGuide; +import dev.isxander.controlify.impl.gui.screen.*; +import dev.isxander.controlify.impl.ingame.ControllerPlayerMovement; +import dev.isxander.controlify.impl.ingame.InGameInputHandler; +import dev.isxander.controlify.impl.input.mapping.ControllerMappingManager; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.platform.network.SidedNetworkApi; +import dev.isxander.controlify.impl.screenop.ScreenBehaviourRegistryImpl; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardLayoutManager; +import dev.isxander.controlify.impl.server.*; +import dev.isxander.controlify.impl.server.packets.*; +import dev.isxander.controlify.impl.sound.ControlifyClientSounds; +import dev.isxander.controlify.impl.utils.*; +import dev.isxander.controlify.impl.utils.event.EventBus; +import dev.isxander.controlify.impl.screenop.ScreenRuntime; +import dev.isxander.controlify.impl.wireless.LowBatteryNotifier; +import dev.isxander.controlify.mixins.feature.virtualmouse.MouseHandlerAccessor; +import dev.isxander.yacl3.gui.YACLScreen; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.PauseScreen; +import net.minecraft.client.multiplayer.ServerData; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; +import org.spongepowered.asm.mixin.MixinEnvironment; + +import static dev.isxander.controlify.impl.utils.ControllerUtils.wrapControllerError; + +import java.io.IOException; +import java.util.*; + +public class Controlify implements ControlifyApi { + /// This holder can initialize before the Controlify singleton or any managers. + public static final class Events { + public static final EventBus INITIALIZED = new EventBus<>(); + public static final EventBus INPUT_MODE_CHANGED = new EventBus<>(); + + private Events() { + } + } + + private static Controlify instance = null; + + private Minecraft minecraft = null; + + private ControllerCoordinator controllerCoordinator; + private InputMode currentInputMode = InputMode.KEYBOARD_MOUSE; + + private ConfigManager config; + + private @Nullable InGameInputHandler inGameInputHandler; + public @Nullable InGameButtonGuide inGameButtonGuide; + + // Asset reloaders / managers + private InputFontMapper inputFontMapper; + private DefaultBindManager defaultBindManager; + private DefaultConfigManager defaultConfigManager; + private ControllerTypeManager controllerTypeManager; + private KeyboardLayoutManager keyboardLayoutManager; + private RadialIconManager radialIconManager; + private ActivatorConfigManager activatorConfigManager; + private ControllerMappingManager controllerMappingManager; + private RumbleSourceManager rumbleSourceManager; + + private TriggerEffectManager triggerEffectManager; + + private SetupWizardManager setupWizardManager; + + private int consecutiveInputSwitches = 0; + private double lastInputSwitchTime = 0; + + private int showMouseTicks = 0; + private boolean mouseUsedThisFrame = false; + + /** + * Called at usual fabric client entrypoint and in NeoForge mod constructor + * Always runs, even with no controllers detected. + * In this state, Controlify is only partially loaded, no controllers + * have been initialised, nor has the config. This is done at {@link Controlify#initializeControlify()}. + * This is where regular fabric callbacks and forge events should be registered. + * On NeoForge, this is run so early that Minecraft.getInstance() has not yet been set. So extra care should be taken as to not use it. + */ + public void preInitialiseControlify() { + DebugProperties.printProperties(); + + CUtil.LOGGER.log("Pre-initializing Controlify..."); + + if (DebugProperties.MIXIN_AUDIT) { + MixinEnvironment.getCurrentEnvironment().audit(); + } + + this.config = new ConfigManager( + PlatformMainUtil.getConfigDir() + ); + PlatformClientUtil.registerClientStopping(_ -> { + this.getControllerCoordinator().ifPresent(ControllerCoordinator::close); + this.config.close(); + }); + + this.setupWizardManager = new SetupWizardManager(); + + this.inputFontMapper = new InputFontMapper(); + this.defaultBindManager = new DefaultBindManager(); + this.defaultConfigManager = new DefaultConfigManager(); + this.controllerTypeManager = new ControllerTypeManager(); + this.keyboardLayoutManager = new KeyboardLayoutManager(); + this.radialIconManager = new RadialIconManager(); + this.activatorConfigManager = new ActivatorConfigManager(); + this.controllerMappingManager = new ControllerMappingManager(); + this.rumbleSourceManager = new RumbleSourceManager(); + + PlatformClientUtil.registerAssetReloadListener(this.inputFontMapper); + PlatformClientUtil.registerAssetReloadListener(this.defaultBindManager); + PlatformClientUtil.registerAssetReloadListener(this.defaultConfigManager); + PlatformClientUtil.registerAssetReloadListener(this.controllerTypeManager); + PlatformClientUtil.registerAssetReloadListener(this.keyboardLayoutManager); + PlatformClientUtil.registerAssetReloadListener(this.radialIconManager); + PlatformClientUtil.registerAssetReloadListener(this.activatorConfigManager); + PlatformClientUtil.registerAssetReloadListener(this.controllerMappingManager); + PlatformClientUtil.registerAssetReloadListener(this.rumbleSourceManager); + PlatformClientUtil.registerAssetReloadListener(RuleSetManager.GUIDE_RULES); + PlatformClientUtil.registerAssetReloadListener(RuleSetManager.TRIGGER_EFFECT_RULES); + ContextualDomains.touch(); + try { + ContextualDomains.Events.REGISTER_DOMAINS.emit(ContextualDomains.INSTANCE); + } finally { + ContextualDomains.INSTANCE.lock(); + } + + registerBuiltinPack("legacy_console"); + + ControlifyClientSounds.init(); + + ControlifyHandshake.setupOnClient(); + + SidedNetworkApi.S2C().listenForPacket(VibrationPacket.CHANNEL, packet -> { + if (config().getSettings().globalSettings().allowServerRumble) { + getCurrentController().flatMap(ControllerImpl::rumble).ifPresent(rumble -> + rumble.rumbleManager().play(VibrationPacket.CHANNEL, packet.source(), packet.createEffect())); + } + }); + SidedNetworkApi.S2C().listenForPacket(OriginVibrationPacket.CHANNEL, packet -> { + if (config().getSettings().globalSettings().allowServerRumble) { + getCurrentController().flatMap(ControllerImpl::rumble).ifPresent(rumble -> + rumble.rumbleManager().play(OriginVibrationPacket.CHANNEL, packet.source(), packet.createEffect())); + } + }); + SidedNetworkApi.S2C().listenForPacket(EntityVibrationPacket.CHANNEL, packet -> { + if (config().getSettings().globalSettings().allowServerRumble) { + getCurrentController().flatMap(ControllerImpl::rumble).ifPresent(rumble -> + rumble.rumbleManager().play(EntityVibrationPacket.CHANNEL, packet.source(), packet.createEffect())); + } + }); + SidedNetworkApi.S2C().listenForPacket(ServerPolicyPacket.CHANNEL, packet -> { + CUtil.LOGGER.log("Connected server specified '{}' policy is {}.", packet.id(), packet.allowed() ? "ALLOWED" : "DISALLOWED"); + ServerPolicies policy = ServerPolicies.getById(packet.id()); + if (policy == null) { + CUtil.LOGGER.warn("Connected server specified unknown '{}' policy; ignoring it.", packet.id()); + return; + } + policy.set(ServerPolicy.fromBoolean(packet.allowed())); + }); + + PlatformClientUtil.registerClientDisconnected(_ -> { + DebugLog.log("Disconnected from server, resetting server policies"); + ServerPolicies.unsetAll(); + }); + PlatformClientUtil.registerClientTagsUpdated(_ -> { + ContextualDomains.INSTANCE.invalidateResolvedFactGraphs(); + RuleSetManager.GUIDE_RULES.invalidateResolvedRuleEngines(); + RuleSetManager.TRIGGER_EFFECT_RULES.invalidateResolvedRuleEngines(); + }); + + PlatformClientUtil.addHudLayer(CUtil.rl("button_guide"), (graphics, deltaTracker) -> + inGameButtonGuide().ifPresent(guide -> guide.extractRenderState(graphics, deltaTracker.getGameTimeDeltaPartialTick(false)))); + } + + private void registerBuiltinPack(String id) { + PlatformClientUtil.registerBuiltinResourcePack( + CUtil.rl(id), + Component.translatable("controlify.extra_pack." + id + ".name") + ); + } + + /** + * Called once Minecraft has completely loaded. + * (When the loading overlay starts to fade). + *

+ * This is where controllers are usually initialised, as long + * as one or more controllers are connected. + */ + public void initializeControlify() { + CUtil.LOGGER.log("Initializing Controlify..."); + this.minecraft = Minecraft.getInstance(); + + config().loadOrDefault(); + + this.inGameInputHandler = null; // set when the current controller changes + this.triggerEffectManager = new TriggerEffectManager(); + + ActionLayerRegistryImpl.INSTANCE.registerBuiltinContexts(); + ControlifyActions.init(); + + PlatformClientUtil.registerPostScreenRender((screen, graphics, mouseX, mouseY, tickDelta) -> + this.getCurrentController().ifPresent(controller -> { + var context = ScreenRuntime.current(); + if (context != null && context.screen() == screen) { + context.extractRenderState(graphics, tickDelta, controller); + } + })); + + this.controllerCoordinator = new ControllerCoordinator(this, this.setupWizardManager); + + // Built-in compatibility must subscribe before addon-facing lifecycle events. + ControlifyCompat.init(); + Events.INITIALIZED.emit(this); + ScreenBehaviourRegistryImpl.INSTANCE.initialize(); + ScreenRuntime.onScreenChanged(); + try { + try { + ActionLayerRegistryImpl.Events.REGISTER_LAYERS.emit(ActionLayerRegistryImpl.INSTANCE); + } finally { + ActionLayerRegistryImpl.INSTANCE.lock(); + } + ActionSpecRegistryImpl.Events.REGISTER_ACTIONS.emit(ActionSpecRegistryImpl.INSTANCE); + ActionProxyManagerImpl.Events.REGISTER_PROXIES.emit(ActionProxyManagerImpl.INSTANCE); + ControlifyActions.registerModdedActions(); + } finally { + ActionSpecRegistryImpl.INSTANCE.lock(); + ActionProxyManagerImpl.INSTANCE.lock(); + } + + if (!this.controllerCoordinator.initialize()) { + return; + } + PlatformClientUtil.registerClientTickStarted(_ -> this.fixedUpdate()); + + discoverControllers(); + + if (DebugProperties.INIT_DUMP) { + CUtil.LOGGER.log("\n{}", DebugDump.dumpDebug()); + } + + if (this.config().getSettings().globalSettings().useEnhancedSteamDeckDriver) { + doSteamDeckChecks(); + } + } + + private void doSteamDeckChecks() { + CUtil.LOGGER.log("Steam Deck state: {}", SteamDeckUtil.DECK_MODE); + + if (!SteamDeckUtil.IS_STEAM_DECK && DebugProperties.STEAM_DECK_CUSTOM_CEF_URL == null) { + return; + } + + boolean connectedToCef = SteamDeckUtil.getDeckInstance().isPresent(); + + if (!connectedToCef) { + CUtil.LOGGER.error("Controlify could not connect to CEF debugger instance. Decky is probably not installed."); + InitialScreenRegistryDuck.registerInitialScreen(SteamDeckAlerts::createDeckyRequiredWarning); + } + + if (SteamDeckUtil.DECK_MODE == SteamDeckMode.DESKTOP_MODE) { + CUtil.LOGGER.warn("Controlify is running in SteamOS desktop mode."); + InitialScreenRegistryDuck.registerInitialScreen(SteamDeckAlerts::createDesktopModeWarning); + } + + if (connectedToCef && SteamDeckUtil.DECK_MODE == SteamDeckMode.GAMING_MODE) { + CUtil.LOGGER.log("Steam Deck is in gaming mode and Controlify has successfully connected to CEF."); + } + } + + /// Discovers already-connected controllers once the backend is initialized. + public void discoverControllers() { + this.getControllerCoordinator().ifPresent(ControllerCoordinator::discoverControllers); + } + + /// Called once per frame, just after Minecraft has done its own SDL event polling. + public void update(float tickDelta) { + if (this.minecraft == null || this.controllerCoordinator == null || !this.controllerCoordinator.isReady()) { + return; + } + + boolean outOfFocus = !config().getSettings().globalSettings().outOfFocusInput + && !this.minecraft.isWindowActive(); + boolean givingInput = this.controllerCoordinator.update(outOfFocus); + + this.setupWizardManager.tick(); + + // handle showing/hiding mouse whilst in mixed input mode + if (this.minecraft.mouseHandler.isMouseGrabbed()) { + showMouseTicks = 0; + } + this.getCurrentController().ifPresent(currentController -> { + wrapControllerError( + () -> updateActiveController(currentController, givingInput), + "Updating current controller", + currentController + ); + }); + + this.mouseUsedThisFrame = false; + } + + /// Called once per frame, after minecraft has done its full tick + /// {@link fixedUpdate} may have been called multiple times between + /// {@link update} and {@link postUpdate}, or it may not have been called. + public void postUpdate(float tickDelta) { + if (this.controllerCoordinator != null) { + this.controllerCoordinator.postUpdate(); + } + } + + /// Called at 20tps + public void fixedUpdate() { + LowBatteryNotifier.tick(); + + if (currentInputMode() == InputMode.MIXED && showMouseTicks > 0) { + showMouseTicks--; + if (showMouseTicks == 0) { + restoreVirtualCursor(); + } + } + + this.controllerCoordinator.fixedUpdate(); + + boolean outOfFocus = !config().getSettings().globalSettings().outOfFocusInput + && !this.minecraft.isWindowActive(); + + this.getCurrentController().ifPresent(currentController -> { + wrapControllerError( + () -> fixedUpdateActiveController(currentController, outOfFocus), + "Fixed-updating current controller", + currentController + ); + }); + } + + private void updateActiveController(ControllerImpl controller, boolean givingInput) { + if (givingInput) { + minecraft.getFramerateLimitTracker().onInputReceived(); + + if (showMouseTicks > 0 && !mouseUsedThisFrame) { + this.endTemporaryCursor(); + } + + if (!this.currentInputMode().isController()) { + this.setInputMode(config().getSettings().globalSettings().mixedInput ? InputMode.MIXED : InputMode.CONTROLLER); + + return; // don't process input if this is changing mode. + } + } + + if (consecutiveInputSwitches > 100) { + CUtil.LOGGER.warn("Controlify detected current controller to be constantly giving input and has been disabled."); + MinecraftUtil.sendToast( + Component.translatable("controlify.toast.faulty_input.title"), + Component.translatable("controlify.toast.faulty_input.description"), + true + ); + this.setCurrentController(null, true); + consecutiveInputSwitches = 0; + return; + } + } + + private void fixedUpdateActiveController(ControllerImpl controller, boolean outOfFocus) { + boolean controllerInputSuppressed = outOfFocus || currentInputMode() == InputMode.KEYBOARD_MOUSE; + this.triggerEffectManager().tick(this.minecraft, controller); + this.triggerEffectManager().applyTriggerEffects(controller, controllerInputSuppressed); + + boolean isPaused = minecraft.isPaused() || MinecraftUtil.getScreen() instanceof PauseScreen; + boolean isConfigScreen = MinecraftUtil.getScreen() instanceof YACLScreen; + + Optional rumbleManager = controller.rumble().map(RumbleComponentImpl::rumbleManager); + rumbleManager.ifPresent(rumble -> rumble.setSilent(outOfFocus || (isPaused && !isConfigScreen) || currentInputMode() == InputMode.KEYBOARD_MOUSE)); + if (!outOfFocus) { + rumbleManager.ifPresent(RumbleManager::tick); + } + + if (minecraft.level != null) { + this.inGameInputHandler().ifPresent(InGameInputHandler::fixedUpdate); + } + + if (this.currentInputMode().isController()) { + if (MinecraftUtil.getScreen() != null) { + var context = ScreenRuntime.current(); + if (context != null) { + context.handleControllerInput(controller); + } + } + } + } + + public void applyControllerSelection(boolean changeInputMode) { + this.getControllerCoordinator().ifPresent(coordinator -> coordinator.applyControllerSelection(changeInputMode)); + } + + public boolean switchProfile(int profileIndex, boolean remember) throws IOException { + if (!config().switchProfile(profileIndex, remember)) { + return false; + } + + this.getControllerCoordinator().ifPresent(ControllerCoordinator::applyActiveProfile); + this.setupForController(this.currentInputMode.isController() ? this.getCurrentController().orElse(null) : null); + return true; + } + + public ConfigManager config() { + return config; + } + + @Override + public @NotNull Optional getCurrentController() { + return this.getControllerCoordinator().flatMap(ControllerCoordinator::currentController); + } + + @Override + public Collection getConnectedControllers() { + return this.getControllerManager() + .map(manager -> List.copyOf(manager.getAllControllers())) + .orElseGet(List::of); + } + + @Override + public Optional getControllerByUid(@NonNull String uid) { + return this.getControllerManager().flatMap(manager -> manager.getControllerByUid(uid)); + } + + @Override + public BuiltinContextualDomains contextualDomains() { + return ContextualDomains.INSTANCE; + } + + public void setCurrentController(@Nullable ControllerImpl controller, boolean changeInputMode) { + this.getControllerCoordinator().ifPresent(coordinator -> coordinator.setCurrentController(controller, changeInputMode)); + } + + /// Internal selection callback; invoked before public selection notification. + public void onCurrentControllerChanged(@Nullable ControllerImpl controller, boolean changeInputMode) { + boolean changedInputMode = false; + if (controller == null) { + changedInputMode = this.setInputMode(InputMode.KEYBOARD_MOUSE); + } else if (changeInputMode) { + changedInputMode = this.setInputMode(config().getSettings().globalSettings().mixedInput ? InputMode.MIXED : InputMode.CONTROLLER); + } + if (!changedInputMode) { + this.setupForController(this.currentInputMode.isController() ? controller : null); + } + } + + @Override + public boolean setInputMode(@NotNull InputMode newInputMode) { + if (this.currentInputMode == newInputMode) { + return false; + } + if (newInputMode.isController() && this.getCurrentController().isEmpty()) { + DebugLog.log("Attempted to switch to controller input mode with no current controller set."); + return false; + } + + this.currentInputMode = newInputMode; + + // Track consecutive input mode switches to prevent softlock + if (Blaze3D.getTime() - lastInputSwitchTime < 20) { + consecutiveInputSwitches++; + } else { + consecutiveInputSwitches = 0; + } + lastInputSwitchTime = Blaze3D.getTime(); + + if (!minecraft.mouseHandler.isMouseGrabbed()) { + if (newInputMode == InputMode.KEYBOARD_MOUSE && ScreenRuntime.virtualMouse().map(mouse -> mouse.isEnabled()).orElse(false)) { + moveCursorToVirtualMouse(); + } + hideMouse(newInputMode.isController(), true); + } + + this.setupForController(this.currentInputMode.isController() ? this.getCurrentController().orElse(null) : null); + + KeyMapping.resetToggleKeys(); + + // If we have already joined a server with KB&M, then switch to controller, + // we should do the new server notification as it won't have been triggered on join. + if (this.currentInputMode.isController()) { + if (minecraft.getCurrentServer() != null) { + notifyNewServer(minecraft.getCurrentServer()); + } + } + + ScreenRuntime.virtualMouse().ifPresent(mouse -> mouse.synchronizeMode()); + + // notify event listeners of input mode change + Events.INPUT_MODE_CHANGED.emit(new ControlifyEvents.InputModeChanged(this, newInputMode)); + + return true; + } + + private void setupForController(@Nullable ControllerImpl controller) { + ControllerPlayerMovement.updatePlayerInput(minecraft.player); + + if (controller == null) { + this.inGameInputHandler = null; + this.inGameButtonGuide = null; + return; + } + + this.inGameInputHandler = new InGameInputHandler(controller); + this.inGameButtonGuide = new InGameButtonGuide(controller, this.minecraft); + + controller.input().resetActions(); + } + + public Optional getControllerManager() { + return this.getControllerCoordinator().map(ControllerCoordinator::controllerManager); + } + + public Optional getControllerCoordinator() { + return Optional.ofNullable(this.controllerCoordinator); + } + + public Optional inGameInputHandler() { + return Optional.ofNullable(inGameInputHandler); + } + + public Optional inGameButtonGuide() { + return Optional.ofNullable(inGameButtonGuide); + } + + public @NotNull InputMode currentInputMode() { + return currentInputMode; + } + + public void hideMouse(boolean hide, boolean moveMouse) { + CursorUtils.setVisibility(minecraft.getWindow(), !hide); + + if (MinecraftUtil.getScreen() != null) { + var mouseHandlerAccessor = (MouseHandlerAccessor) minecraft.mouseHandler; + if (hide && !ScreenRuntime.virtualMouse().map(mouse -> mouse.isEnabled()).orElse(false) && !ScreenRuntime.virtualMouse().map(mouse -> mouse.isRequired()).orElse(false) && moveMouse) { + long handle = minecraft.getWindow().handle(); + // stop mouse hovering over last element before hiding cursor but don't actually move it + // so when the user switches back to mouse it will be in the same place + mouseHandlerAccessor.controlify$invokeOnMove(handle, -50, -50 /*? if >=26.3 {*/, 0, 0/*?}*/); + } + } + } + + public void showCursorTemporarily() { + if (currentInputMode() == InputMode.MIXED && !minecraft.mouseHandler.isMouseGrabbed()) { + mouseUsedThisFrame = true; + if (ScreenRuntime.virtualMouse().map(mouse -> mouse.isEnabled()).orElse(false)) { + moveCursorToVirtualMouse(); + ScreenRuntime.virtualMouse().ifPresent(mouse -> mouse.setSuspended(true)); + } + hideMouse(false, false); + showMouseTicks = 20 * 2; + } + } + + private void moveCursorToVirtualMouse() { + ScreenRuntime.virtualMouse().ifPresent(mouse -> CursorUtils.setPosition( + minecraft.getWindow(), mouse.getCurrentX(0), mouse.getCurrentY(0))); + } + + public void endTemporaryCursor() { + if (currentInputMode() != InputMode.MIXED || showMouseTicks <= 0) { + return; + } + + showMouseTicks = 0; + restoreVirtualCursor(); + } + + private void restoreVirtualCursor() { + hideMouse(true, false); + ScreenRuntime.virtualMouse().ifPresent(mouse -> mouse.setSuspended(false)); + } + + public InputFontMapper inputFontMapper() { + return this.inputFontMapper; + } + + public DefaultBindManager defaultBindManager() { + return this.defaultBindManager; + } + + public DefaultConfigManager defaultConfigManager() { + return this.defaultConfigManager; + } + + public ControllerTypeManager controllerTypeManager() { + return this.controllerTypeManager; + } + + public KeyboardLayoutManager keyboardLayoutManager() { + return this.keyboardLayoutManager; + } + + public RadialIconManager radialIconManager() { + return this.radialIconManager; + } + + public ActivatorConfigManager activatorConfigManager() { + return this.activatorConfigManager; + } + + public ControllerMappingManager controllerMappingManager() { + return this.controllerMappingManager; + } + + public RumbleSourceManager rumbleSourceManager() { + return this.rumbleSourceManager; + } + + public TriggerEffectManager triggerEffectManager() { + return this.triggerEffectManager; + } + + public void notifyNewServer(ServerData data) { + if (!currentInputMode().isController()) { + return; + } + + if (config().getSettings().globalSettings().seenServers.add(data.ip)) { + MinecraftUtil.sendToast( + Component.translatable("controlify.toast.new_server.title"), + Component.translatable("controlify.toast.new_server.description", data.name), + true + ); + config().saveSafely(); + } + } + + public static Controlify instance() { + if (instance == null) { + instance = new Controlify(); + } + return instance; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/Accumulator.java b/src/main/java/dev/isxander/controlify/impl/action/Accumulator.java new file mode 100644 index 000000000..cdcf89dc2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/Accumulator.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +/// Accepts action state. +public interface Accumulator { + default void firePulse() { + } + + default void toggleLatch() { + } + default void setLatch(boolean active) { + } + + default void setContinuous(float value) { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionAccessorImpl.java b/src/main/java/dev/isxander/controlify/impl/action/ActionAccessorImpl.java new file mode 100644 index 000000000..91e3f21a9 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionAccessorImpl.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.controller.input.InputComponent; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +public record ActionAccessorImpl(Identifier actionId) implements ActionAccessor { + @Override + public @Nullable Action onOrNull(@NonNull InputComponent inputComponent) { + return inputComponent.getAction(this.actionId); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionCodecs.java b/src/main/java/dev/isxander/controlify/impl/action/ActionCodecs.java new file mode 100644 index 000000000..415af4ed0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionCodecs.java @@ -0,0 +1,58 @@ +package dev.isxander.controlify.impl.action; + +import com.mojang.datafixers.util.Either; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.action.Gesture; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.utils.codec.CExtraCodecs; +import net.minecraft.resources.Identifier; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/// All action-related codecs whose classes would be in the api package are here +/// so their CODECs do not become a part of the api. +public final class ActionCodecs { + + private static final Codec LOGICAL_INPUT_SINGLE = + Identifier.CODEC.xmap(LogicalInput.Single::new, LogicalInput.Single::physicalInput); + + private static final Codec LOGICAL_INPUT_MODIFIED_CHORD = + RecordCodecBuilder., Identifier>>create(instance -> instance.group( + CExtraCodecs.set(Identifier.CODEC).fieldOf("modifiers").forGetter(Map.Entry::getKey), + Identifier.CODEC.fieldOf("trigger").forGetter(Map.Entry::getValue) + ).apply(instance, Map::entry)).flatXmap( + entry -> LogicalInput.ModifiedChord.validate(entry.getKey(), entry.getValue()) + .>map(error -> DataResult.error(() -> error)) + .orElseGet(() -> DataResult.success(new LogicalInput.ModifiedChord(entry.getKey(), entry.getValue()))), + chord -> DataResult.success(Map.entry(chord.modifiers(), chord.trigger())) + ); + + public static final Codec LOGICAL_INPUT = + CExtraCodecs.fuzzy( + List.of(LOGICAL_INPUT_SINGLE, LOGICAL_INPUT_MODIFIED_CHORD), + input -> switch (input) { + case LogicalInput.Single _ -> LOGICAL_INPUT_SINGLE; + case LogicalInput.ModifiedChord _ -> LOGICAL_INPUT_MODIFIED_CHORD; + } + ); + + + private static final Codec BINDING_RECORD = RecordCodecBuilder.create(instance -> instance.group( + ActionCodecs.LOGICAL_INPUT.fieldOf("input").forGetter(ActionBinding::input), + Gesture.CODEC.optionalFieldOf("gesture").forGetter(ActionBinding::gesture) + ).apply(instance, ActionBinding::new)); + + /// A bare input, or `{"input": ..., "gesture": ...}`. + public static final Codec BINDING = Codec.either(ActionCodecs.LOGICAL_INPUT, BINDING_RECORD).xmap( + either -> either.map(ActionBinding::of, binding -> binding), + binding -> binding.gesture().isEmpty() ? Either.left(binding.input()) : Either.right(binding) + ); + + private ActionCodecs() { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionDispatcher.java b/src/main/java/dev/isxander/controlify/impl/action/ActionDispatcher.java new file mode 100644 index 000000000..aed10305e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionDispatcher.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.ActionLayerMode; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import java.util.*; + +/// Routes logical signals to actions through the stack of active layers. +/// +/// Input is resolved from the top layer down: each layer's {@link ActionLayerMode} decides +/// whether an input continues to the layers below it. The result is cached as a dispatch table +/// and only rebuilt when the active layers or bindings change. +/// +/// Button gestures belong to the actions that received the press. Actions that become reachable +/// mid-press never see its release, tap or hold; actions that stop being reachable mid-press +/// receive {@link LogicalSignal.Cancelled} instead of the rest of it. +/// +/// The pipeline emits every chord a press could form, most specific first, before the single input. +/// The first of those that reaches an action claims its physical buttons until it is released: +/// less specific inputs using a claimed button are suppressed, and presses already holding one, +/// such as the modifier's own single press, are cancelled. +public class ActionDispatcher implements EventSink { + // use a sorted set to have deterministic ordering + private final Set allActions = new TreeSet<>(); + + // Context ID -> Logical Input -> set of ActionImpl + // the same compiled binding can be registered for multiple contexts and inputs + private final Map>> bindings = new HashMap<>(); + + /// Active contexts, top to bottom. + private List currentContexts = List.of(); + /// Logical input -> actions that receive it given the current contexts. + private Map> dispatch = Map.of(); + private boolean dispatchDirty = true; + + /// Logical input -> actions that received its current press. + private final Map> pressOwners = new HashMap<>(); + /// Logical input -> actions that owned its last completed press, for its deferred {@link LogicalSignal.Tapped}. + private final Map> tapOwners = new HashMap<>(); + /// Physical input -> the chord that claimed it. + private final Map claims = new HashMap<>(); + private final Map continuousInputs = new HashMap<>(); + private long lastSignalNanos; + + public void addAction(ActionImpl action) { + if (!this.allActions.add(action)) { + throw new IllegalArgumentException("This action has already been added"); + } + for (var contextId : action.spec().contexts()) { + var byInputMap = this.bindings.computeIfAbsent(contextId, _ -> new HashMap<>()); + for (LogicalInput input : action.boundInputs()) { + byInputMap.computeIfAbsent(input, _ -> new TreeSet<>()).add(action); + } + } + this.dispatchDirty = true; + } + + public void removeAction(ActionImpl action) { + for (var contextId : action.spec().contexts()) { + var byInputMap = this.bindings.getOrDefault(contextId, Map.of()); + byInputMap.values().removeIf(set -> set.remove(action) && set.isEmpty()); + } + this.allActions.remove(action); + this.dispatchDirty = true; + } + + public void clearActions() { + this.allActions.clear(); + this.bindings.clear(); + this.claims.clear(); + this.pressOwners.clear(); + this.tapOwners.clear(); + this.dispatch = Map.of(); + this.dispatchDirty = true; + } + + public void onActionBindingChange(ActionImpl action) { + // re-indexes the action in the binding index + this.removeAction(action); + this.addAction(action); + } + + /// @param contexts the active contexts, ordered from top to bottom + public void setCurrentContexts(List contexts) { + if (contexts.equals(this.currentContexts)) { + return; + } + this.currentContexts = List.copyOf(contexts); + this.dispatchDirty = true; + } + + /// Whether another action currently receives this input alongside the given one, + /// given the active layers. Prompts use this to decide whether a gesture needs spelling out. + public boolean isSharedWithOtherActions(ActionImpl action, LogicalInput input) { + this.rebuildDispatchIfDirty(); + Set actions = this.dispatch.getOrDefault(input, Set.of()); + return actions.size() > (actions.contains(action) ? 1 : 0); + } + + /// Releases state from a physical mapping that is being replaced. + public void resetInputState() { + this.continuousInputs.clear(); + this.pressOwners.clear(); + this.tapOwners.clear(); + this.claims.clear(); + this.allActions.forEach(ActionImpl::reset); + } + + /// Resets every action, abandoning presses in progress while keeping held axis values. + public void resetActions() { + this.pressOwners.clear(); + this.tapOwners.clear(); + this.claims.clear(); + this.allActions.forEach(ActionImpl::reset); + this.rebuildDispatchIfDirty(); + this.dispatch.forEach((input, actions) -> actions.forEach(action -> this.restoreContinuousState(action, input))); + } + + @Override + public void accept(@NonNull LogicalSignal event) { + this.lastSignalNanos = event.timeNanos(); + this.rebuildDispatchIfDirty(); + + switch (event) { + case LogicalSignal.Frame frame -> this.processFrame(frame); + case LogicalSignal.AxisMoved axis -> this.processAxis(axis); + case LogicalSignal.ButtonPress press -> this.processPress(press); + case LogicalSignal.InputSignal gesture -> this.processGesture(gesture); + case LogicalSignal.SensorInterval _ -> {} // ignore sensor data + } + } + + private void processFrame(LogicalSignal.Frame frame) { + for (ActionImpl action : this.allActions) { + action.activator().onSignal(frame, action.state()); + } + } + + private void processAxis(LogicalSignal.AxisMoved axis) { + if (axis.value() == 0f) { + this.continuousInputs.remove(axis.input()); + } else { + this.continuousInputs.put(axis.input(), new LogicalSignal.AxisMoved( + axis.timeNanos(), axis.input(), axis.value(), 0f)); + } + if (this.isClaimedByOther(axis.input())) { + return; + } + this.deliver(axis, this.dispatch.getOrDefault(axis.input(), Set.of())); + } + + private void processPress(LogicalSignal.ButtonPress press) { + LogicalInput input = press.input(); + Set targets = this.dispatch.getOrDefault(input, Set.of()); + if (targets.isEmpty() || this.isClaimedByOther(input)) { + this.pressOwners.remove(input); + return; + } + if (input instanceof LogicalInput.ModifiedChord) { + this.claim(input); + } + this.pressOwners.put(input, new TreeSet<>(targets)); + this.deliver(press, targets); + } + + private boolean isClaimedByOther(LogicalInput input) { + for (Identifier physical : input.physicalInputs()) { + LogicalInput claimer = this.claims.get(physical); + if (claimer != null && !claimer.equals(input)) { + return true; + } + } + return false; + } + + private void claim(LogicalInput chord) { + Set physical = chord.physicalInputs(); + physical.forEach(input -> this.claims.put(input, chord)); + + // The modifiers were pressed first, so their own presses may already be owned. + List overlapping = this.pressOwners.keySet().stream() + .filter(other -> !other.equals(chord)) + .filter(other -> other.physicalInputs().stream().anyMatch(physical::contains)) + .toList(); + for (LogicalInput other : overlapping) { + for (ActionImpl owner : this.pressOwners.remove(other)) { + owner.activator().onSignal(new LogicalSignal.Cancelled(this.lastSignalNanos, other), owner.state()); + } + } + } + + /// Release, tap, double tap, hold and GUI press only reach the actions that saw the press. + private void processGesture(LogicalSignal.InputSignal gesture) { + LogicalInput input = gesture.input(); + if (gesture instanceof LogicalSignal.Tapped) { + // Taps arrive after the double tap window, possibly once a newer press has taken ownership. + Set owners = this.tapOwners.remove(input); + if (owners != null) { + this.deliver(gesture, owners); + } + return; + } + + Set owners = this.pressOwners.get(input); + if (owners != null) { + this.deliver(gesture, owners); + } + if (gesture instanceof LogicalSignal.ButtonRelease) { + this.claims.values().removeIf(input::equals); + if (owners != null) { + this.tapOwners.put(input, new TreeSet<>(owners)); + } else { + this.tapOwners.remove(input); + } + } + } + + private void deliver(LogicalSignal signal, Set actions) { + for (ActionImpl action : actions) { + action.activator().onSignal(signal, action.state()); + } + } + + private void rebuildDispatchIfDirty() { + if (!this.dispatchDirty) { + return; + } + this.dispatchDirty = false; + + Map> previous = this.dispatch; + Map> next = resolveDispatch(this.currentContexts, this.bindings); + this.dispatch = next; + + // Actions that stopped receiving an input must not be left holding it. + previous.forEach((input, actions) -> { + Set stillReachable = next.getOrDefault(input, Set.of()); + for (ActionImpl action : actions) { + if (!stillReachable.contains(action)) { + this.cancel(action, input); + } + } + }); + this.pressOwners.values().removeIf(owners -> owners.isEmpty()); + this.tapOwners.entrySet().removeIf(entry -> { + entry.getValue().retainAll(next.getOrDefault(entry.getKey(), Set.of())); + return entry.getValue().isEmpty(); + }); + + // Actions that started receiving an axis pick up its value without replaying presses. + next.forEach((input, actions) -> { + Set wasReachable = previous.getOrDefault(input, Set.of()); + for (ActionImpl action : actions) { + if (!wasReachable.contains(action)) { + this.restoreContinuousState(action, input); + } + } + }); + } + + private void cancel(ActionImpl action, LogicalInput input) { + Set owners = this.pressOwners.get(input); + if (owners != null) { + owners.remove(action); + } + // The bindings may have changed since the action was reachable, in which case + // it was already reset and the cancel is a no-op for its new inputs. + action.activator().onSignal(new LogicalSignal.Cancelled(this.lastSignalNanos, input), action.state()); + } + + private void restoreContinuousState(ActionImpl action, LogicalInput input) { + var value = this.continuousInputs.get(input); + if (value != null) { + // Restore values without replaying presses, pulses or held-button latches. + action.activator().onSignal(value, new Accumulator() { + @Override + public void setContinuous(float value) { + action.state().setContinuous(value); + } + }); + } + } + + /// Walks the contexts from top to bottom, collecting the actions each input reaches. + static Map> resolveDispatch( + List contexts, + Map>> bindings + ) { + Map> dispatch = new HashMap<>(); + Set consumed = new HashSet<>(); + + for (ActionLayer context : contexts) { + Map> byInput = bindings.getOrDefault(context.id(), Map.of()); + byInput.forEach((input, actions) -> { + if (!actions.isEmpty() && !consumed.contains(input)) { + dispatch.computeIfAbsent(input, _ -> new TreeSet<>()).addAll(actions); + } + }); + + switch (context.mode()) { + case TRANSPARENT -> {} + case OVERLAY -> byInput.forEach((input, actions) -> { + if (!actions.isEmpty()) { + consumed.add(input); + } + }); + case BLOCKING -> { + return dispatch; + } + } + } + return dispatch; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionImpl.java b/src/main/java/dev/isxander/controlify/impl/action/ActionImpl.java new file mode 100644 index 000000000..cda6f7bde --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionImpl.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.action.ActionSpec; +import dev.isxander.controlify.api.action.Gesture; +import dev.isxander.controlify.api.action.Channel; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.action.activator.Activator; +import dev.isxander.controlify.impl.action.activator.ActivatorConfig; +import dev.isxander.controlify.impl.action.activator.LeafConfig; +import dev.isxander.controlify.impl.action.activator.NoopActivator; +import dev.isxander.controlify.impl.utils.CUtil; +import org.jspecify.annotations.NonNull; + +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +public class ActionImpl implements Action, Comparable { + private final ActionSpec spec; + private final ActionState state; + private List bindings; + private List defaultBindings; + private Set boundInputs; + private Activator activator; + private final ActivatorConfig activatorConfig; + + private final ActionDispatcher actionDispatcher; + + public ActionImpl( + ActionSpec spec, + List bindings, + List defaultBindings, + ActivatorConfig activator, + ActionDispatcher actionDispatcher + ) { + this.spec = spec; + this.defaultBindings = List.copyOf(defaultBindings); + this.activatorConfig = activator; + this.state = new ActionState(); + this.actionDispatcher = actionDispatcher; + this.setBindingsSilently(bindings); + } + + @Override + public @NonNull ActionSpec spec() { + return this.spec; + } + + @Override + public @NonNull List bindings() { + return this.bindings; + } + + /// The distinct inputs across every binding. + public Set boundInputs() { + return this.boundInputs; + } + + @Override + public void setBindings(@NonNull List bindings) { + this.setBindingsSilently(bindings); + this.reset(); + this.actionDispatcher.onActionBindingChange(this); + } + + private void setBindingsSilently(List bindings) { + this.warnUnappliedGestures(bindings); + this.bindings = List.copyOf(bindings); + this.boundInputs = this.bindings.stream() + .map(ActionBinding::input) + .collect(Collectors.toUnmodifiableSet()); + } + + /// A binding's gesture is ignored unless the activator has an editable leaf of the same kind. + private void warnUnappliedGestures(List bindings) { + Optional editableLeaf = this.activatorConfig.editableLeaf(); + for (ActionBinding binding : bindings) { + if (binding.gesture().isEmpty()) { + continue; + } + Gesture gesture = binding.gesture().get(); + if (editableLeaf.isEmpty()) { + CUtil.LOGGER.warn("Ignoring gesture '{}' on {} for action {}: its activator has no editable gesture", + gesture.getSerializedName(), binding.input(), this.spec.id()); + } else if (editableLeaf.get().gestureFor(binding).orElse(null) != gesture) { + CUtil.LOGGER.warn("Ignoring gesture '{}' on {} for action {}: its editable activator only accepts {} gestures", + gesture.getSerializedName(), binding.input(), this.spec.id(), + editableLeaf.get().gesture().map(leafGesture -> leafGesture.kind().name().toLowerCase(Locale.ROOT)).orElse("no")); + } + } + } + + @Override + public @NonNull List defaultBindings() { + return this.defaultBindings; + } + + /// Refreshes resource-defined defaults without changing the current bindings or activator state. + public void setDefaultBindings(List defaultBindings) { + this.defaultBindings = List.copyOf(defaultBindings); + } + + /// Whether another action currently receives this input too, given the active layers. + public boolean isInputShared(LogicalInput input) { + return this.actionDispatcher.isSharedWithOtherActions(this, input); + } + + public ActivatorConfig activatorConfig() { + return this.activatorConfig; + } + + @Override + public Channel.@NonNull Pulse pulse() { + return this.state(); + } + + @Override + public Channel.@NonNull Latch latch() { + return this.state(); + } + + @Override + public Channel.@NonNull Continuous continuous() { + return this.state(); + } + + public void reset() { + this.activator = this.bindings.isEmpty() + ? new NoopActivator() + : this.activatorConfig.create(this.bindings); + this.state.reset(); + } + + public ActionState state() { + return this.state; + } + + public Activator activator() { + return this.activator; + } + + @Override + public int compareTo(@NonNull ActionImpl o) { + return this.spec().compareTo(o.spec()); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionLayer.java b/src/main/java/dev/isxander/controlify/impl/action/ActionLayer.java new file mode 100644 index 000000000..e63dca385 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionLayer.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.ActionLayerMode; +import net.minecraft.client.Minecraft; +import net.minecraft.resources.Identifier; + +import java.util.function.Predicate; + +/// Internal layer definition; the public API exposes its identifier and registration parameters only. +public record ActionLayer(Identifier id, ActionLayerMode mode, Predicate isActive) { +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionLayerRegistryImpl.java b/src/main/java/dev/isxander/controlify/impl/action/ActionLayerRegistryImpl.java new file mode 100644 index 000000000..a652cc0a7 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionLayerRegistryImpl.java @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.ActionLayerRegistry; +import dev.isxander.controlify.api.action.BuiltinActionLayers; +import dev.isxander.controlify.api.action.ActionLayerMode; +import dev.isxander.controlify.impl.gui.screen.RadialMenuScreen; +import dev.isxander.controlify.impl.screenop.ScreenRuntime; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.impl.utils.event.EventBus; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import dev.isxander.controlify.impl.utils.CUtil; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +public final class ActionLayerRegistryImpl implements ActionLayerRegistry { + /// Subscriptions do not initialize the context registry singleton. + public static final class Events { + public static final EventBus REGISTER_LAYERS = new EventBus<>(); + + private Events() { + } + } + + public static final ActionLayerRegistryImpl INSTANCE = new ActionLayerRegistryImpl(); + + private final Map contexts = new HashMap<>(); + private final Map placements = new HashMap<>(); + /// Top to bottom; populated when registration closes. + private List orderedContexts = List.of(); + private boolean locked; + + private ActionLayerRegistryImpl() { + } + + @Override + public @NonNull Builder layer(@NonNull Identifier id) { + return new BuilderImpl(Objects.requireNonNull(id)); + } + + public void registerBuiltinContexts() { + // Always applicable, so it sits above everything and consumes nothing. + this.layer(BuiltinActionLayers.UNKNOWN) + .mode(ActionLayerMode.TRANSPARENT) + .activeWhen(mc -> true) + .top() + .register(); + // Radial actions shadow screen actions on the same inputs; screen-wide actions such as back still work. + this.layer(BuiltinActionLayers.RADIAL_MENU) + .mode(ActionLayerMode.OVERLAY) + .activeWhen(mc -> MinecraftUtil.getScreen() instanceof RadialMenuScreen) + .above(BuiltinActionLayers.V_MOUSE_COMPAT) + .register(); + // Screen sub-contexts overlap on purpose; screen behaviours decide between their actions. + this.layer(BuiltinActionLayers.V_MOUSE_COMPAT) + .mode(ActionLayerMode.TRANSPARENT) + .activeWhen(mc -> MinecraftUtil.getScreen() != null + && ScreenRuntime.virtualMouse().map(mouse -> mouse.isEnabled() && mouse.mode().emulatesClicks()).orElse(false)) + .above(BuiltinActionLayers.V_MOUSE_CURSOR) + .register(); + this.layer(BuiltinActionLayers.V_MOUSE_CURSOR) + .mode(ActionLayerMode.TRANSPARENT) + .activeWhen(mc -> MinecraftUtil.getScreen() != null + && ScreenRuntime.virtualMouse().map(mouse -> mouse.isEnabled()).orElse(false)) + .above(BuiltinActionLayers.CONTAINER) + .register(); + this.layer(BuiltinActionLayers.CONTAINER) + .mode(ActionLayerMode.TRANSPARENT) + .activeWhen(mc -> MinecraftUtil.getScreen() instanceof AbstractContainerScreen) + .above(BuiltinActionLayers.REGULAR_SCREEN) + .register(); + this.layer(BuiltinActionLayers.REGULAR_SCREEN) + .mode(ActionLayerMode.TRANSPARENT) + .activeWhen(mc -> MinecraftUtil.getScreen() != null + && !ScreenRuntime.virtualMouse().map(mouse -> mouse.isEnabled()).orElse(false)) + .above(BuiltinActionLayers.ANY_SCREEN) + .register(); + // Any open screen hides gameplay. + this.layer(BuiltinActionLayers.ANY_SCREEN) + .mode(ActionLayerMode.BLOCKING) + .activeWhen(mc -> MinecraftUtil.getScreen() != null) + .above(BuiltinActionLayers.IN_GAME) + .register(); + this.layer(BuiltinActionLayers.IN_GAME) + .mode(ActionLayerMode.OVERLAY) + .activeWhen(mc -> mc.level != null && mc.player != null) + .below(BuiltinActionLayers.ANY_SCREEN) + .register(); + } + + /// @return the active contexts, ordered from top to bottom + public List getActiveContexts(Minecraft minecraft) { + if (!this.locked) { + throw new IllegalStateException("Action contexts must be registered before evaluating them"); + } + List active = new ArrayList<>(this.orderedContexts.size()); + for (ActionLayer context : this.orderedContexts) { + if (context.isActive().test(minecraft)) { + active.add(context); + } + } + return active; + } + + /// @return every registered context, ordered from top to bottom + public List getOrderedContexts() { + return this.orderedContexts; + } + + public void lock() { + if (this.locked) { + return; + } + this.locked = true; + this.orderedContexts = LayerOrdering.sort(this.placements, CUtil.LOGGER::warn).stream() + .map(this.contexts::get) + .toList(); + } + + private final class BuilderImpl implements Builder { + private final Identifier id; + private ActionLayerMode mode = ActionLayerMode.OVERLAY; + private @Nullable Predicate isActive; + private final Set above = new LinkedHashSet<>(); + private final Set below = new LinkedHashSet<>(); + private boolean top, bottom; + + private BuilderImpl(Identifier id) { + this.id = id; + } + + @Override + public @NonNull Builder mode(@NonNull ActionLayerMode mode) { + this.mode = Objects.requireNonNull(mode); + return this; + } + + @Override + public @NonNull Builder activeWhen(@NonNull Predicate isActive) { + this.isActive = Objects.requireNonNull(isActive); + return this; + } + + @Override + public @NonNull Builder above(@NonNull Identifier other) { + this.above.add(Objects.requireNonNull(other)); + return this; + } + + @Override + public @NonNull Builder below(@NonNull Identifier other) { + this.below.add(Objects.requireNonNull(other)); + return this; + } + + @Override + public @NonNull Builder top() { + this.top = true; + return this; + } + + @Override + public @NonNull Builder bottom() { + this.bottom = true; + return this; + } + + @Override + public @NonNull Identifier register() { + ActionLayerRegistryImpl registry = ActionLayerRegistryImpl.this; + if (registry.locked) { + throw new IllegalStateException("Action context registration has closed"); + } + if (this.isActive == null) { + throw new IllegalStateException("Action context " + this.id + " needs a predicate"); + } + var placement = new LayerOrdering.Placement(this.above, this.below, this.top, this.bottom); + if (placement.isEmpty()) { + throw new IllegalStateException("Action context " + this.id + " needs a placement relative to other contexts"); + } + if (registry.contexts.containsKey(this.id)) { + throw new IllegalArgumentException("Action context is already registered: " + this.id); + } + registry.contexts.put(this.id, new ActionLayer(this.id, this.mode, this.isActive)); + registry.placements.put(this.id, placement); + return this.id; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionSpecBuilderImpl.java b/src/main/java/dev/isxander/controlify/impl/action/ActionSpecBuilderImpl.java new file mode 100644 index 000000000..1166278ac --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionSpecBuilderImpl.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.ActionSpec; +import dev.isxander.controlify.api.action.ActionSpecBuilder; +import dev.isxander.controlify.api.controller.Controller; +import net.minecraft.locale.Language; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import org.apache.commons.lang3.Validate; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.*; +import java.util.function.Predicate; + +public class ActionSpecBuilderImpl implements ActionSpecBuilder { + private @Nullable Identifier id; + private @Nullable Component name; + private @Nullable Component description; + private @Nullable Component category; + private final Set contexts = new TreeSet<>(); + private Predicate shouldExpose = _ -> true; + + @Override + public @NonNull ActionSpecBuilder id(@NonNull Identifier id) { + this.id = id; + return this; + } + + @Override + public @NonNull ActionSpecBuilder name(@NonNull Component name) { + this.name = name; + return this; + } + + @Override + public @NonNull ActionSpecBuilder description(@NonNull Component description) { + this.description = description; + return this; + } + + @Override + public @NonNull ActionSpecBuilder category(@NonNull Component category) { + this.category = category; + return this; + } + + @Override + public @NonNull ActionSpecBuilder layer(Identifier @NonNull ... contexts) { + this.contexts.addAll(List.of(contexts)); + return this; + } + + @Override + public @NonNull ActionSpecBuilder shouldExpose(@NonNull Predicate predicate) { + this.shouldExpose = predicate; + return this; + } + + @Override + public @NonNull ActionSpec build() { + Validate.notNull(this.id, "An action spec needs an id"); + Validate.notNull(this.category, "An action spec needs a category"); + Validate.isTrue(!this.contexts.isEmpty(), "An action spec needs at least one context"); + + Component name = Optional.ofNullable(this.name) + .orElseGet(() -> Component.translatable(this.id.toLanguageKey("controlify.action"))); + Component description = Optional.ofNullable(this.description) + .or(() -> { + String key = this.id.toLanguageKey("controlify.action", "desc"); + if (Language.getInstance().has(key)) { + return Optional.of(Component.translatable(key)); + } else { + return Optional.empty(); + } + }) + .orElse(Component.empty()); + + return new ActionSpec( + this.id, + name, + description, + this.category, + Collections.unmodifiableSet(this.contexts), + this.shouldExpose + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionSpecRegistryImpl.java b/src/main/java/dev/isxander/controlify/impl/action/ActionSpecRegistryImpl.java new file mode 100644 index 000000000..f2f31b53f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionSpecRegistryImpl.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.action.ActionSpec; +import dev.isxander.controlify.api.action.ActionSpecRegistry; +import dev.isxander.controlify.impl.utils.event.EventBus; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class ActionSpecRegistryImpl implements ActionSpecRegistry { + /// Addons can subscribe without initializing the registry singleton. + public static final class Events { + public static final EventBus REGISTER_ACTIONS = new EventBus<>(); + + private Events() { + } + } + + public static final ActionSpecRegistryImpl INSTANCE = new ActionSpecRegistryImpl(); + + private final List registeredSpecs = new ArrayList<>(); + private boolean locked; + + @Override + public ActionAccessor register(@NonNull ActionSpec actionSpec) { + this.checkLocked(false); + if (this.registeredSpecs.stream().anyMatch(spec -> spec.id().equals(actionSpec.id()))) { + throw new IllegalArgumentException("Action is already registered: " + actionSpec.id()); + } + this.registeredSpecs.add(actionSpec); + return new ActionAccessorImpl(actionSpec.id()); + } + + public boolean isRegistered(Identifier actionId) { + return this.registeredSpecs.stream().anyMatch(spec -> spec.id().equals(actionId)); + } + + public List getActionSpecs() { + this.checkLocked(true); + return Collections.unmodifiableList(this.registeredSpecs); + } + + public void lock() { + this.locked = true; + } + + private void checkLocked(boolean required) { + if (required) { + if (!this.locked) { + throw new IllegalStateException("Registry must be locked to perform this operation."); + } + } else { + if (this.locked) { + throw new IllegalStateException("Registry must be unlocked to perform this operation."); + } + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ActionState.java b/src/main/java/dev/isxander/controlify/impl/action/ActionState.java new file mode 100644 index 000000000..4b352d504 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ActionState.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.Channel; + +public final class ActionState implements Accumulator, Channel.Continuous, Channel.Latch, Channel.Pulse { + private int pulses = 0; + private boolean latchActive = false; + private float continuousValue = 0; + private long resetVersion; + + @Override + public float getContinuous() { + return this.continuousValue; + } + + @Override + public boolean isLatchActive() { + return this.latchActive; + } + + @Override + public boolean consumePulse() { + if (this.pulses > 0) { + this.pulses--; + return true; + } + return false; + } + + @Override + public boolean consumeAllPulses() { + if (this.pulses > 0) { + this.pulses = 0; + return true; + } + return false; + } + + @Override + public void toggleLatch() { + this.setLatch(!this.isLatchActive()); + } + + @Override + public void setLatch(boolean active) { + this.latchActive = active; + } + + @Override + public void firePulse() { + this.pulses++; + } + + @Override + public void setContinuous(float value) { + this.continuousValue = Math.clamp(value, 0f, 1f); + } + + public void reset() { + this.resetVersion++; + this.pulses = 0; + this.latchActive = false; + this.continuousValue = 0f; + } + + public long resetVersion() { + return this.resetVersion; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/BindingConflicts.java b/src/main/java/dev/isxander/controlify/impl/action/BindingConflicts.java new file mode 100644 index 000000000..ce5388d98 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/BindingConflicts.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.action.ActionLayerMode; +import dev.isxander.controlify.api.action.Gesture; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.action.activator.ActivatorConfig; +import net.minecraft.resources.Identifier; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/// Finds bindings where one press of an input would activate more than intended. +/// Purely static: it reasons about gestures, inputs and the context stack, not live input. +public final class BindingConflicts { + public enum Kind { + /// Both bindings fire on the same press, in a context they share. + CONFLICT, + /// The binding's button is a modifier of another action's chord, and the binding + /// fires before the chord can claim it. + MODIFIER_LEAK, + /// Another action in a higher, non-transparent context takes this input whenever that context is active. + SHADOWED, + /// The same action already has this input with the same gestures. + REDUNDANT, + } + + /// @param bindings the bindings to check, which may be unsaved + public record Entry(Identifier action, Set contexts, List bindings, ActivatorConfig activator) { + } + + /// Reported on the binding at {@code bindingIndex} of {@code action}, caused by {@code other}'s binding. + public record Conflict(Kind kind, Identifier action, int bindingIndex, Identifier other, int otherBindingIndex) { + } + + /// @param layerOrder every layer, top to bottom + public static List find(List entries, List layerOrder) { + Map position = new HashMap<>(); + Map mode = new HashMap<>(); + for (int i = 0; i < layerOrder.size(); i++) { + position.put(layerOrder.get(i).id(), i); + mode.put(layerOrder.get(i).id(), layerOrder.get(i).mode()); + } + + List conflicts = new ArrayList<>(); + for (Entry a : entries) { + for (int i = 0; i < a.bindings().size(); i++) { + ActionBinding bindingA = a.bindings().get(i); + List> gesturesA = gestures(a.activator(), bindingA); + + for (int j = 0; j < i; j++) { + ActionBinding earlier = a.bindings().get(j); + if (earlier.input().equals(bindingA.input()) && gestures(a.activator(), earlier).equals(gesturesA)) { + conflicts.add(new Conflict(Kind.REDUNDANT, a.action(), i, a.action(), j)); + } + } + + for (Entry b : entries) { + if (b == a) { + continue; + } + boolean shareContext = !Collections.disjoint(a.contexts(), b.contexts()); + for (int j = 0; j < b.bindings().size(); j++) { + ActionBinding bindingB = b.bindings().get(j); + + if (bindingA.input().equals(bindingB.input())) { + if (!overlap(gesturesA, gestures(b.activator(), bindingB))) { + continue; + } + if (shareContext) { + conflicts.add(new Conflict(Kind.CONFLICT, a.action(), i, b.action(), j)); + } else if (shadows(b.contexts(), a.contexts(), position, mode)) { + conflicts.add(new Conflict(Kind.SHADOWED, a.action(), i, b.action(), j)); + } + } else if (shareContext + && bindingA.input() instanceof LogicalInput.Single(Identifier button) + && bindingB.input() instanceof LogicalInput.ModifiedChord chord + && chord.modifiers().contains(button) + && gesturesA.stream().anyMatch(BindingConflicts::firesBeforeRelease)) { + conflicts.add(new Conflict(Kind.MODIFIER_LEAK, a.action(), i, b.action(), j)); + } + } + } + } + } + return conflicts; + } + + /// The gesture of every leaf for this binding; empty for continuous leaves. + private static List> gestures(ActivatorConfig activator, ActionBinding binding) { + return activator.leaves().stream().map(leaf -> leaf.gestureFor(binding)).toList(); + } + + private static boolean overlap(List> a, List> b) { + for (Optional gestureA : a) { + for (Optional gestureB : b) { + if (!Collections.disjoint(outcomes(gestureA), outcomes(gestureB))) { + return true; + } + } + } + return false; + } + + /// Continuous leaves respond to any movement, so they occupy every outcome. + private static Set outcomes(Optional gesture) { + return gesture.map(Gesture::outcomes).orElseGet(() -> EnumSet.allOf(Gesture.Outcome.class)); + } + + private static boolean firesBeforeRelease(Optional gesture) { + return gesture.map(Gesture::firesBeforeRelease).orElse(true); + } + + /// Whether some upper context sits above some lower context and consumes inputs. + private static boolean shadows( + Set upper, Set lower, + Map position, Map mode + ) { + for (Identifier upperContext : upper) { + Integer upperPosition = position.get(upperContext); + if (upperPosition == null || mode.get(upperContext) == ActionLayerMode.TRANSPARENT) { + continue; + } + for (Identifier lowerContext : lower) { + Integer lowerPosition = position.get(lowerContext); + if (lowerPosition != null && upperPosition < lowerPosition) { + return true; + } + } + } + return false; + } + + private BindingConflicts() { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/ControlifyActions.java b/src/main/java/dev/isxander/controlify/impl/action/ControlifyActions.java new file mode 100644 index 000000000..3d4aff6a0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/ControlifyActions.java @@ -0,0 +1,468 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.action.ActionEvents; +import dev.isxander.controlify.api.action.ActionProxyManager; +import dev.isxander.controlify.api.action.ActionSpec; +import dev.isxander.controlify.api.action.ActionSpecBuilder; +import dev.isxander.controlify.api.action.BuiltinActionLayers; +import dev.isxander.controlify.impl.action.proxy.ActionProxyManagerImpl; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.ChatFormatting; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Options; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; + +import java.util.Locale; + +public final class ControlifyActions { + public static final String MODDED_ACTION_NAMESPACE = "controlify_modded"; + + private static final Options options = Minecraft.getInstance().options; + + public static final Component MOVEMENT_CATEGORY = KeyMapping.Category.MOVEMENT.label(); + public static final Component GAMEPLAY_CATEGORY = KeyMapping.Category.GAMEPLAY.label(); + public static final Component INVENTORY_CATEGORY = KeyMapping.Category.INVENTORY.label(); + public static final Component CREATIVE_CATEGORY = KeyMapping.Category.CREATIVE.label(); + public static final Component MISC_CATEGORY = KeyMapping.Category.MISC.label(); + public static final Component DEBUG_CATEGORY = Component.translatable("controlify.binding_category.debug"); + public static final Component GUI_CATEGORY = Component.translatable("controlify.binding_category.gui"); + public static final Component RADIAL_CATEGORY = Component.translatable("controlify.gui.radial_menu"); + public static final Component VMOUSE_CATEGORY = Component.translatable("controlify.binding_category.vmouse"); + + public static final ActionAccessor WALK_FORWARD = register(ActionSpec.builder() + .id(CUtil.rl("walk_forward")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor WALK_BACKWARD = register(ActionSpec.builder() + .id(CUtil.rl("walk_backward")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor WALK_LEFT = register(ActionSpec.builder() + .id(CUtil.rl("strafe_left")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor WALK_RIGHT = register(ActionSpec.builder() + .id(CUtil.rl("strafe_right")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor LOOK_UP = register(ActionSpec.builder() + .id(CUtil.rl("look_up")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor LOOK_DOWN = register(ActionSpec.builder() + .id(CUtil.rl("look_down")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor LOOK_LEFT = register(ActionSpec.builder() + .id(CUtil.rl("look_left")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor LOOK_RIGHT = register(ActionSpec.builder() + .id(CUtil.rl("look_right")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor GYRO_BUTTON = register(ActionSpec.builder() + .id(CUtil.rl("gyro_button")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME) + .shouldExpose(c -> c.gyro().isPresent())); + public static final ActionAccessor JUMP = register(ActionSpec.builder() + .id(CUtil.rl("jump")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor SPRINT = register(ActionSpec.builder() + .id(CUtil.rl("sprint")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor SNEAK = register(ActionSpec.builder() + .id(CUtil.rl("sneak")) + .category(MOVEMENT_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor ATTACK = register(ActionSpec.builder() + .id(CUtil.rl("attack")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor USE = register(ActionSpec.builder() + .id(CUtil.rl("use")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DROP_INGAME = register(ActionSpec.builder() + .id(CUtil.rl("drop")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DROP_STACK = register(ActionSpec.builder() + .id(CUtil.rl("drop_stack")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor PAUSE = register(ActionSpec.builder() + .id(CUtil.rl("pause")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME, BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor CHANGE_PERSPECTIVE = register(ActionSpec.builder() + .id(CUtil.rl("change_perspective")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor SWAP_HANDS = register(ActionSpec.builder() + .id(CUtil.rl("swap_hands")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor NEXT_SLOT = register(ActionSpec.builder() + .id(CUtil.rl("next_slot")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor PREV_SLOT = register(ActionSpec.builder() + .id(CUtil.rl("prev_slot")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_SELECT = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_item_select_radial")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME, BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor HOTBAR_SLOT_1 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_1")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_2 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_2")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_3 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_3")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_4 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_4")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_5 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_5")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_6 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_6")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_7 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_7")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_8 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_8")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_SLOT_9 = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_slot_9")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor GAME_MODE_SWITCHER = register(ActionSpec.builder() + .id(CUtil.rl("game_mode_switcher")) + .category(GAMEPLAY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME, BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor INVENTORY = register(ActionSpec.builder() + .id(CUtil.rl("inventory")) + .category(INVENTORY_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor INV_SELECT = register(ActionSpec.builder() + .id(CUtil.rl("inv_select")) + .category(INVENTORY_CATEGORY) + .layer(BuiltinActionLayers.CONTAINER)); + public static final ActionAccessor INV_QUICK_MOVE = register(ActionSpec.builder() + .id(CUtil.rl("inv_quick_move")) + .category(INVENTORY_CATEGORY) + .layer(BuiltinActionLayers.CONTAINER)); + public static final ActionAccessor INV_TAKE_HALF = register(ActionSpec.builder() + .id(CUtil.rl("inv_take_half")) + .category(INVENTORY_CATEGORY) + .layer(BuiltinActionLayers.CONTAINER)); + public static final ActionAccessor DROP_INVENTORY = register(ActionSpec.builder() + .id(CUtil.rl("drop_inventory")) + .category(INVENTORY_CATEGORY) + .layer(BuiltinActionLayers.CONTAINER, BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor PICK_BLOCK = register(ActionSpec.builder() + .id(CUtil.rl("pick_block")) + .category(CREATIVE_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor PICK_BLOCK_NBT = register(ActionSpec.builder() + .id(CUtil.rl("pick_block_nbt")) + .category(CREATIVE_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor HOTBAR_LOAD_RADIAL = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_load_radial")) + .category(CREATIVE_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME, BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor HOTBAR_SAVE_RADIAL = register(ActionSpec.builder() + .id(CUtil.rl("hotbar_save_radial")) + .category(CREATIVE_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME, BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor OPEN_CHAT = register(ActionSpec.builder() + .id(CUtil.rl("open_chat")) + .category(MISC_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor TOGGLE_HUD_VISIBILITY = register(ActionSpec.builder() + .id(CUtil.rl("toggle_hud_visibility")) + .category(MISC_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor SHOW_PLAYER_LIST = register(ActionSpec.builder() + .id(CUtil.rl("show_player_list")) + .category(MISC_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor TAKE_SCREENSHOT = register(ActionSpec.builder() + .id(CUtil.rl("take_screenshot")) + .category(MISC_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DEBUG_RADIAL = register(ActionSpec.builder() + .id(CUtil.rl("debug_radial")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME, BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor TOGGLE_DEBUG_MENU = register(ActionSpec.builder() + .id(CUtil.rl("toggle_debug_menu")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor TOGGLE_DEBUG_MENU_FPS = register(ActionSpec.builder() + .id(CUtil.rl("toggle_debug_menu_fps")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor TOGGLE_DEBUG_MENU_NET = register(ActionSpec.builder() + .id(CUtil.rl("toggle_debug_menu_net")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor TOGGLE_DEBUG_MENU_PROF = register(ActionSpec.builder() + .id(CUtil.rl("toggle_debug_menu_prof")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DEBUG_RELOAD_CHUNKS = register(ActionSpec.builder() + .id(CUtil.rl("debug_reload_chunks")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DEBUG_TOGGLE_CHUNK_BORDERS = register(ActionSpec.builder() + .id(CUtil.rl("debug_toggle_chunk_borders")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DEBUG_TOGGLE_ADVANCED_TOOLTIPS = register(ActionSpec.builder() + .id(CUtil.rl("debug_toggle_advanced_tooltips")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DEBUG_TOGGLE_ENTITY_HITBOXES = register(ActionSpec.builder() + .id(CUtil.rl("debug_toggle_entity_hitboxes")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DEBUG_RELOAD_RESOURCE_PACKS = register(ActionSpec.builder() + .id(CUtil.rl("debug_reload_resource_packs")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DEBUG_CLEAR_CHAT = register(ActionSpec.builder() + .id(CUtil.rl("debug_clear_chat")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor DEBUG_START_STOP_PROFILING = register(ActionSpec.builder() + .id(CUtil.rl("debug_start_stop_profiling")) + .category(DEBUG_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME)); + public static final ActionAccessor GUI_PRESS = register(ActionSpec.builder() + .id(CUtil.rl("gui_press")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_BACK = register(ActionSpec.builder() + .id(CUtil.rl("gui_back")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.ANY_SCREEN)); + public static final ActionAccessor GUI_NEXT_TAB = register(ActionSpec.builder() + .id(CUtil.rl("gui_next_tab")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.ANY_SCREEN)); + public static final ActionAccessor GUI_PREV_TAB = register(ActionSpec.builder() + .id(CUtil.rl("gui_prev_tab")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.ANY_SCREEN)); + public static final ActionAccessor GUI_ABSTRACT_ACTION_1 = register(ActionSpec.builder() + .id(CUtil.rl("gui_abstract_action_1")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_ABSTRACT_ACTION_2 = register(ActionSpec.builder() + .id(CUtil.rl("gui_abstract_action_2")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_ABSTRACT_ACTION_3 = register(ActionSpec.builder() + .id(CUtil.rl("gui_abstract_action_3")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_NAVI_UP = register(ActionSpec.builder() + .id(CUtil.rl("gui_navi_up")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_NAVI_DOWN = register(ActionSpec.builder() + .id(CUtil.rl("gui_navi_down")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_NAVI_LEFT = register(ActionSpec.builder() + .id(CUtil.rl("gui_navi_left")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_NAVI_RIGHT = register(ActionSpec.builder() + .id(CUtil.rl("gui_navi_right")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_SECONDARY_NAVI_UP = register(ActionSpec.builder() + .id(CUtil.rl("gui_secondary_navi_up")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.CONTAINER, BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_SECONDARY_NAVI_DOWN = register(ActionSpec.builder() + .id(CUtil.rl("gui_secondary_navi_down")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.CONTAINER, BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_SECONDARY_NAVI_LEFT = register(ActionSpec.builder() + .id(CUtil.rl("gui_secondary_navi_left")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.CONTAINER, BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor GUI_SECONDARY_NAVI_RIGHT = register(ActionSpec.builder() + .id(CUtil.rl("gui_secondary_navi_right")) + .category(GUI_CATEGORY) + .layer(BuiltinActionLayers.CONTAINER, BuiltinActionLayers.REGULAR_SCREEN)); + public static final ActionAccessor RADIAL_MENU = register(ActionSpec.builder() + .id(CUtil.rl("radial_menu")) + .category(RADIAL_CATEGORY) + .layer(BuiltinActionLayers.IN_GAME, BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor RADIAL_AXIS_UP = register(ActionSpec.builder() + .id(CUtil.rl("radial_axis_up")) + .category(RADIAL_CATEGORY) + .layer(BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor RADIAL_AXIS_DOWN = register(ActionSpec.builder() + .id(CUtil.rl("radial_axis_down")) + .category(RADIAL_CATEGORY) + .layer(BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor RADIAL_AXIS_LEFT = register(ActionSpec.builder() + .id(CUtil.rl("radial_axis_left")) + .category(RADIAL_CATEGORY) + .layer(BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor RADIAL_AXIS_RIGHT = register(ActionSpec.builder() + .id(CUtil.rl("radial_axis_right")) + .category(RADIAL_CATEGORY) + .layer(BuiltinActionLayers.RADIAL_MENU)); + public static final ActionAccessor VMOUSE_MOVE_UP = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_move_up")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_MOVE_DOWN = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_move_down")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_MOVE_LEFT = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_move_left")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_MOVE_RIGHT = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_move_right")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_SNAP_UP = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_snap_up")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_SNAP_DOWN = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_snap_down")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_SNAP_LEFT = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_snap_left")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_SNAP_RIGHT = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_snap_right")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_LCLICK = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_lclick")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_RCLICK = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_rclick")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_SHIFT_CLICK = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_shift_click")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_SCROLL_DOWN = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_scroll_down")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_SCROLL_UP = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_scroll_up")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_SHIFT = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_shift")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_PAGE_NEXT = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_page_next")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_PAGE_PREV = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_page_prev")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_PAGE_DOWN = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_page_down")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_PAGE_UP = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_page_up")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.V_MOUSE_CURSOR)); + public static final ActionAccessor VMOUSE_TOGGLE = register(ActionSpec.builder() + .id(CUtil.rl("vmouse_toggle")) + .category(VMOUSE_CATEGORY) + .layer(BuiltinActionLayers.V_MOUSE_CURSOR, BuiltinActionLayers.V_MOUSE_COMPAT, BuiltinActionLayers.ANY_SCREEN)); + + private static ActionAccessor register(ActionSpecBuilder builder) { + return ActionSpecRegistryImpl.INSTANCE.register(builder.build()); + } + + public static void init() { + ActionEvents.REGISTER_PROXIES.subscribeForever(ControlifyActions::registerProxies); + } + + private static void registerProxies(ActionProxyManager proxies) { + proxies.proxyKeyMapping(SPRINT, options.keySprint, + controller -> ((ControllerImpl) controller).settings().generic.toggleSprint); + proxies.proxyKeyMapping(ATTACK, options.keyAttack); + proxies.proxyKeyMapping(USE, options.keyUse); + } + + public static void registerModdedActions() { + for (KeyMapping keyMapping : PlatformClientUtil.getModdedKeyMappings()) { + if (!ActionProxyManagerImpl.INSTANCE.shouldAutomaticallyProxyKeyMapping(keyMapping)) { + continue; + } + try { + String idPath = keyMapping.getName().toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9/._-]", "_").trim(); + Identifier id = Identifier.fromNamespaceAndPath(MODDED_ACTION_NAMESPACE, idPath); + ActionAccessor action = register(ActionSpec.builder() + .id(id) + .name(Component.translatable(keyMapping.getName())) + .description(Component.translatable("controlify.custom_binding.vanilla_description").withStyle(ChatFormatting.GRAY)) + .category(keyMapping.getCategory().label()) + .layer(BuiltinActionLayers.IN_GAME)); + ActionProxyManagerImpl.INSTANCE.proxyKeyMapping(action, keyMapping); + } catch (Exception e) { + CUtil.LOGGER.error("Failed to automatically register modded action: {}", keyMapping.getName(), e); + } + } + } + + private ControlifyActions() { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/KeyMappingHandle.java b/src/main/java/dev/isxander/controlify/impl/action/KeyMappingHandle.java new file mode 100644 index 000000000..61f2ffcf5 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/KeyMappingHandle.java @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +/// Applies emulated output without changing Minecraft's keyboard toggle policy. +public interface KeyMappingHandle { + void controlify$click(); + + void controlify$setDown(boolean down); +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/LayerOrdering.java b/src/main/java/dev/isxander/controlify/impl/action/LayerOrdering.java new file mode 100644 index 000000000..8026eaf92 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/LayerOrdering.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action; + +import net.minecraft.resources.Identifier; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Consumer; + +/// Resolves relative layer placements into a single top-to-bottom order. +/// Layers with no relation between them are ordered by id, so the result +/// does not depend on registration (and therefore mod load) order. +public final class LayerOrdering { + public record Placement(Set above, Set below, boolean top, boolean bottom) { + public Placement { + above = Set.copyOf(above); + below = Set.copyOf(below); + if (top && bottom) { + throw new IllegalArgumentException("A layer cannot be placed at both the top and bottom"); + } + } + + public boolean isEmpty() { + return this.above.isEmpty() && this.below.isEmpty() && !this.top && !this.bottom; + } + } + + /// @param placements every layer id and its placement + /// @param warn receives a message for each placement relative to an unregistered layer + /// @return the layer ids ordered from top to bottom + /// @throws IllegalStateException if the placements contradict each other + public static List sort(Map placements, Consumer warn) { + // edges point from a context to every context directly below it + Map> below = new HashMap<>(); + Map aboveCount = new HashMap<>(); + for (Identifier id : placements.keySet()) { + below.put(id, new TreeSet<>()); + aboveCount.put(id, 0); + } + + placements.forEach((id, placement) -> { + for (Identifier other : placement.above()) { + addEdge(below, id, other, id, warn); + } + for (Identifier other : placement.below()) { + addEdge(below, other, id, id, warn); + } + if (placement.top() || placement.bottom()) { + placements.forEach((otherId, otherPlacement) -> { + if (placement.top() && !otherPlacement.top()) { + below.get(id).add(otherId); + } + if (placement.bottom() && !otherPlacement.bottom()) { + below.get(otherId).add(id); + } + }); + } + }); + below.values().forEach(set -> set.forEach(lower -> aboveCount.merge(lower, 1, Integer::sum))); + + TreeSet ready = new TreeSet<>(); + aboveCount.forEach((id, count) -> { + if (count == 0) { + ready.add(id); + } + }); + + List order = new ArrayList<>(placements.size()); + while (!ready.isEmpty()) { + Identifier next = ready.pollFirst(); + order.add(next); + for (Identifier lower : below.get(next)) { + if (aboveCount.merge(lower, -1, Integer::sum) == 0) { + ready.add(lower); + } + } + } + + if (order.size() != placements.size()) { + List cyclic = placements.keySet().stream() + .filter(id -> !order.contains(id)) + .sorted() + .toList(); + throw new IllegalStateException("Action layer placements form a cycle between: " + cyclic); + } + return List.copyOf(order); + } + + private static void addEdge( + Map> below, + Identifier upper, Identifier lower, + Identifier declaredBy, Consumer warn + ) { + if (!below.containsKey(upper) || !below.containsKey(lower)) { + Identifier missing = below.containsKey(upper) ? lower : upper; + warn.accept("Action layer " + declaredBy + " is placed relative to unregistered layer " + missing + "; ignoring"); + return; + } + if (upper.equals(lower)) { + throw new IllegalStateException("Action layer " + declaredBy + " cannot be placed relative to itself"); + } + below.get(upper).add(lower); + } + + private LayerOrdering() { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/Activator.java b/src/main/java/dev/isxander/controlify/impl/action/activator/Activator.java new file mode 100644 index 000000000..b7cb79589 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/Activator.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import dev.isxander.controlify.impl.action.Accumulator; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; + +/// Processes signals and activates an {@link dev.isxander.controlify.api.action.Action}. +/// +/// Activators form a tree. Leaves ({@link LeafSlot}) read the logical signals of the action's bindings; +/// every other activator transforms the output of its children and never reads inputs directly. +public interface Activator { + /// Accepts a {@link LogicalSignal} and potentially submits state into the action accumulator. + /// Activators wrapping others must forward every signal, including {@link LogicalSignal.Cancelled}. + void onSignal(LogicalSignal signal, Accumulator acc); + + /// gives a string representation of this activator + String describe(); +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfig.java b/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfig.java new file mode 100644 index 000000000..9b45812de --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfig.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import dev.isxander.controlify.api.action.ActionBinding; +import net.minecraft.util.StringRepresentable; + +import java.util.List; +import java.util.Optional; + +/// An immutable structure containing the configuration for an {@link Activator} tree, +/// allowing you to create instances of it for an action's bindings. +public interface ActivatorConfig { + /// Creates the activator tree. Each leaf gets one instance per binding; everything above the + /// leaves exists once and sees the bindings merged, as if there were a single binding. + T create(List bindings); + + ActivatorConfigType type(); + + /// Every leaf in this tree, depth first. + List leaves(); + + /// The single leaf whose gesture a binding may override, if any. + default Optional editableLeaf() { + return this.leaves().stream().filter(LeafConfig::editable).findFirst(); + } + + Codec> CODEC = Codec.recursive("ActivatorConfig", self -> { + Codec> typeCodec = StringRepresentable.fromValues(() -> ActivatorConfigType.TYPES); + Codec> fullCodec = typeCodec.dispatch("type", ActivatorConfig::type, ActivatorConfigType::mapCodec); + Codec> shorthandCodec = typeCodec.flatXmap( + type -> type.unitConfig() + .>>map(DataResult::success) + .orElseGet(() -> DataResult.error(() -> "Activator type '" + type.id() + "' requires an object configuration")), + config -> DataResult.success(config.type()) + ); + + // Alternatives are accepted when decoding; encoding always uses the full object form. + return fullCodec.withAlternative(shorthandCodec) + .withAlternative(self.listOf(), CompoundActivator.Config::new) + .validate(config -> config.leaves().stream().filter(LeafConfig::editable).count() > 1 + ? DataResult.error(() -> "An activator may have at most one editable leaf") + : DataResult.success(config)); + }); +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfigManager.java b/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfigManager.java new file mode 100644 index 000000000..4dea8f342 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfigManager.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; +import dev.isxander.controlify.api.action.Gesture; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ActionSpecRegistryImpl; +import dev.isxander.controlify.impl.action.proxy.ActionProxyManagerImpl; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; + +import java.io.BufferedReader; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public class ActivatorConfigManager implements SimpleControlifyReloadListener>> { + private static final Identifier DEFINITIONS = CUtil.rl("controller/activators.json"); + private static final Codec>> CODEC = Codec.unboundedMap(Identifier.CODEC, ActivatorConfig.CODEC); + + private static final ActivatorConfig FALLBACK = + new CompoundActivator.Config(List.of()); + /// Mod key mappings are usually pressed, but players may move them to a hold to share a button. + private static final ActivatorConfig PROXY_ACTIVATOR = + new SpanLeafConfig(Gesture.WHILE_PRESSED, true, true); + + private volatile Map> configsByAction = Map.of(); + + public ActivatorConfig getConfig(Identifier actionId) { + return configsByAction.getOrDefault( + actionId, + ActionProxyManagerImpl.INSTANCE.isKeyMappingProxy(actionId) ? PROXY_ACTIVATOR : FALLBACK + ); + } + + @Override + public CompletableFuture>> load(ResourceManager manager, Executor executor) { + return CompletableFuture.supplyAsync(() -> { + Map> mappings = new HashMap<>(); + + // Resource stacks are returned from lowest to highest priority, so later + // packs overwrite individual actions from earlier packs. + for (Resource resource : manager.getResourceStack(DEFINITIONS)) { + try (BufferedReader reader = resource.openAsReader()) { + JsonElement json = JsonParser.parseReader(reader); + CODEC.parse(JsonOps.INSTANCE, json) + .resultOrPartial(error -> CUtil.LOGGER.error( + "Failed to parse activator configs from pack '{}': {}", + resource.sourcePackId(), + error + )) + .ifPresent(mappings::putAll); + } catch (Exception e) { + CUtil.LOGGER.error("Failed to load activator configs from pack '{}'", resource.sourcePackId(), e); + } + } + + return Map.copyOf(mappings); + }, executor); + } + + @Override + public CompletableFuture apply(Map> data, ResourceManager manager, Executor executor) { + return CompletableFuture.runAsync(() -> { + configsByAction = data; + Controlify.instance().getControllerManager().ifPresent(controllerManager -> { + for (var controller : controllerManager.getAllControllers()) { + controller.input().reloadActions(); + } + }); + }, executor); + } + + @Override + public Identifier getReloadId() { + return CUtil.rl("activators"); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfigType.java b/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfigType.java new file mode 100644 index 000000000..bfe035e70 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/ActivatorConfigType.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import com.mojang.serialization.MapCodec; +import net.minecraft.util.StringRepresentable; +import org.jetbrains.annotations.NotNull; + +import java.util.Optional; + +public record ActivatorConfigType>( + String id, + MapCodec mapCodec, + Optional unitConfig +) implements StringRepresentable { + public ActivatorConfigType(String id, MapCodec mapCodec) { + this(id, mapCodec, Optional.empty()); + } + + public ActivatorConfigType(String id, C unitConfig) { + this(id, MapCodec.unit(unitConfig), Optional.of(unitConfig)); + } + + public static final ActivatorConfigType COMPOUND = + new ActivatorConfigType<>(CompoundActivator.Config.TYPE_ID, CompoundActivator.Config.MAP_CODEC); + + public static final ActivatorConfigType PULSE = + new ActivatorConfigType<>(PulseLeafConfig.TYPE_ID, PulseLeafConfig.MAP_CODEC, Optional.of(PulseLeafConfig.DEFAULT)); + + public static final ActivatorConfigType SPAN = + new ActivatorConfigType<>(SpanLeafConfig.TYPE_ID, SpanLeafConfig.MAP_CODEC, Optional.of(SpanLeafConfig.DEFAULT)); + + public static final ActivatorConfigType CONTINUOUS = + new ActivatorConfigType<>(ContinuousLeafConfig.TYPE_ID, ContinuousLeafConfig.INSTANCE); + + public static final ActivatorConfigType REPEAT_LATCH_PULSE = + new ActivatorConfigType<>(RepeatLatchPulseActivator.Config.TYPE_ID, RepeatLatchPulseActivator.Config.MAP_CODEC); + + public static final ActivatorConfigType TOGGLE_LATCH = + new ActivatorConfigType<>(ToggleLatchActivator.Config.TYPE_ID, ToggleLatchActivator.Config.MAP_CODEC); + + public static final ActivatorConfigType[] TYPES = new ActivatorConfigType[]{ + COMPOUND, PULSE, SPAN, CONTINUOUS, REPEAT_LATCH_PULSE, TOGGLE_LATCH + }; + + @Override + public @NotNull String getSerializedName() { + return this.id(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/CompoundActivator.java b/src/main/java/dev/isxander/controlify/impl/action/activator/CompoundActivator.java new file mode 100644 index 000000000..492b905bf --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/CompoundActivator.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.impl.action.Accumulator; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; + +import java.util.List; +import java.util.stream.Collectors; + +public record CompoundActivator(List activators) implements Activator { + + @Override + public void onSignal(LogicalSignal signal, Accumulator acc) { + for (var activator : activators) { + activator.onSignal(signal, acc); + } + } + + @Override + public String describe() { + return activators().stream() + .map(Activator::describe) + .collect(Collectors.joining(",", "Compound[", "]")); + } + + public record Config(List> activators) implements ActivatorConfig { + public static final String TYPE_ID = "compound"; + public static final MapCodec MAP_CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + ActivatorConfig.CODEC.listOf().fieldOf("activators").forGetter(Config::activators) + ).apply(instance, Config::new)); + + @Override + public CompoundActivator create(List bindings) { + var activators = this.activators.stream() + .map(config -> config.create(bindings)) + .toList(); + return new CompoundActivator(activators); + } + + @Override + public List leaves() { + return this.activators.stream().flatMap(config -> config.leaves().stream()).toList(); + } + + @Override + public ActivatorConfigType type() { + return ActivatorConfigType.COMPOUND; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/ContinuousLeafConfig.java b/src/main/java/dev/isxander/controlify/impl/action/activator/ContinuousLeafConfig.java new file mode 100644 index 000000000..8586e9083 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/ContinuousLeafConfig.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import dev.isxander.controlify.api.action.Gesture; + +import java.util.Optional; + +/// Mirrors the bound axis value into the continuous channel. +public record ContinuousLeafConfig() implements LeafConfig { + public static final String TYPE_ID = "continuous"; + public static final ContinuousLeafConfig INSTANCE = new ContinuousLeafConfig(); + + @Override + public Kind kind() { + return Kind.CONTINUOUS; + } + + @Override + public Optional gesture() { + return Optional.empty(); + } + + @Override + public boolean editable() { + return false; + } + + @Override + public ActivatorConfigType type() { + return ActivatorConfigType.CONTINUOUS; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/LeafConfig.java b/src/main/java/dev/isxander/controlify/impl/action/activator/LeafConfig.java new file mode 100644 index 000000000..4f3b823a0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/LeafConfig.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.action.Gesture; + +import java.util.List; +import java.util.Optional; + +/// An activator that reads the action's bindings directly. Every other activator transforms leaves. +public interface LeafConfig extends ActivatorConfig { + Kind kind(); + + /// The gesture used unless a binding overrides it; empty for continuous leaves. + Optional gesture(); + + /// Whether a binding may override {@link #gesture()} with another of the same kind. + boolean editable(); + + /// The gesture this leaf uses for the given binding. + default Optional gestureFor(ActionBinding binding) { + Optional override = binding.gesture(); + if (this.editable() && override.isPresent() && this.gesture().isPresent() + && override.get().kind() == this.gesture().get().kind()) { + return override; + } + return this.gesture(); + } + + @Override + default LeafSlot create(List bindings) { + return new LeafSlot(this, bindings); + } + + @Override + default List leaves() { + return List.of(this); + } + + enum Kind { + /// Pulses on an instant gesture. Merged across bindings by summing pulses. + PULSE, + /// Holds the latch for a span gesture and pulses once when it starts. + /// Merged across bindings: active while any binding's span is. + SPAN, + /// Mirrors an axis value. Merged across bindings by taking the largest value. + CONTINUOUS, + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/LeafSlot.java b/src/main/java/dev/isxander/controlify/impl/action/activator/LeafSlot.java new file mode 100644 index 000000000..f4aa177f7 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/LeafSlot.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.action.Gesture; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.action.Accumulator; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.stream.Collectors; + +/// One leaf position of an activator tree: an instance per binding, merged into a single output. +public final class LeafSlot implements Activator { + private final LeafConfig.Kind kind; + private final boolean pulseOnStart; + private final List instances; + private boolean mergedActive; + private float mergedValue; + + public LeafSlot(LeafConfig config, List bindings) { + this.kind = config.kind(); + this.pulseOnStart = !(config instanceof SpanLeafConfig span) || span.pulseOnStart(); + // A binding listed twice must not pulse twice. + this.instances = bindings.stream() + .map(binding -> new Key(binding.input(), config.gestureFor(binding).orElse(null))) + .distinct() + .map(key -> new Instance(key.input(), key.gesture())) + .toList(); + } + + @Override + public void onSignal(LogicalSignal signal, Accumulator acc) { + if (!(signal instanceof LogicalSignal.InputSignal inputSignal)) { + return; + } + for (Instance instance : this.instances) { + if (instance.input.equals(inputSignal.input())) { + instance.accept(this.kind, inputSignal); + } + } + + switch (this.kind) { + case PULSE -> { + for (Instance instance : this.instances) { + for (; instance.pulses > 0; instance.pulses--) { + acc.firePulse(); + } + } + } + case SPAN -> { + boolean active = this.instances.stream().anyMatch(instance -> instance.active); + if (active != this.mergedActive) { + this.mergedActive = active; + acc.setLatch(active); + if (active && this.pulseOnStart) { + acc.firePulse(); + } + } + } + case CONTINUOUS -> { + float value = 0f; + for (Instance instance : this.instances) { + value = Math.max(value, instance.value); + } + if (value != this.mergedValue) { + this.mergedValue = value; + acc.setContinuous(value); + } + } + } + } + + @Override + public String describe() { + return this.kind + this.instances.stream() + .map(instance -> instance.gesture == null ? instance.input.toString() : instance.gesture.getSerializedName() + " " + instance.input) + .collect(Collectors.joining(", ", "[", "]")); + } + + private record Key(LogicalInput input, @Nullable Gesture gesture) { + } + + private static final class Instance { + private final LogicalInput input; + private final @Nullable Gesture gesture; + private int pulses; + private boolean active; + private float value; + + private Instance(LogicalInput input, @Nullable Gesture gesture) { + this.input = input; + this.gesture = gesture; + } + + private void accept(LeafConfig.Kind kind, LogicalSignal.InputSignal signal) { + switch (kind) { + case PULSE -> { + if (firesOn(this.gesture, signal)) { + this.pulses++; + } + } + case SPAN -> { + if (signal instanceof LogicalSignal.ButtonRelease || signal instanceof LogicalSignal.Cancelled) { + this.active = false; + } else if (this.gesture == Gesture.WHILE_PRESSED && signal instanceof LogicalSignal.ButtonPress + || this.gesture == Gesture.WHILE_HELD && signal instanceof LogicalSignal.Held) { + this.active = true; + } + } + case CONTINUOUS -> { + if (signal instanceof LogicalSignal.AxisMoved axis) { + this.value = axis.value(); + } else if (signal instanceof LogicalSignal.Cancelled) { + this.value = 0f; + } + } + } + } + + private static boolean firesOn(@Nullable Gesture gesture, LogicalSignal.InputSignal signal) { + return gesture != null && switch (gesture) { + case PRESS -> signal instanceof LogicalSignal.ButtonPress; + case RELEASE -> signal instanceof LogicalSignal.ButtonRelease; + case TAP -> signal instanceof LogicalSignal.Tapped; + case DOUBLE_TAP -> signal instanceof LogicalSignal.DoubleTapped; + case HOLD -> signal instanceof LogicalSignal.Held; + case GUI_PRESS -> signal instanceof LogicalSignal.GuiPress; + case WHILE_PRESSED, WHILE_HELD -> false; + }; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/NoopActivator.java b/src/main/java/dev/isxander/controlify/impl/action/activator/NoopActivator.java new file mode 100644 index 000000000..091d1b183 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/NoopActivator.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import dev.isxander.controlify.impl.action.Accumulator; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; + +public class NoopActivator implements Activator { + @Override + public void onSignal(LogicalSignal signal, Accumulator acc) { + } + + @Override + public String describe() { + return "Noop"; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/PulseLeafConfig.java b/src/main/java/dev/isxander/controlify/impl/action/activator/PulseLeafConfig.java new file mode 100644 index 000000000..215d643a0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/PulseLeafConfig.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.api.action.Gesture; + +import java.util.Optional; + +/// Pulses once per instant gesture. +public record PulseLeafConfig(Gesture instantGesture, boolean editable) implements LeafConfig { + public static final String TYPE_ID = "pulse"; + public static final PulseLeafConfig DEFAULT = new PulseLeafConfig(Gesture.PRESS, false); + public static final MapCodec MAP_CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + Gesture.CODEC.optionalFieldOf("gesture", Gesture.PRESS).forGetter(PulseLeafConfig::instantGesture), + Codec.BOOL.optionalFieldOf("editable", false).forGetter(PulseLeafConfig::editable) + ).apply(instance, PulseLeafConfig::new)).validate(config -> config.instantGesture().kind() == Gesture.Kind.INSTANT + ? DataResult.success(config) + : DataResult.error(() -> "A pulse activator needs an instant gesture, not " + config.instantGesture().getSerializedName())); + + @Override + public Kind kind() { + return Kind.PULSE; + } + + @Override + public Optional gesture() { + return Optional.of(this.instantGesture); + } + + @Override + public ActivatorConfigType type() { + return ActivatorConfigType.PULSE; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/RepeatLatchPulseActivator.java b/src/main/java/dev/isxander/controlify/impl/action/activator/RepeatLatchPulseActivator.java new file mode 100644 index 000000000..e16a65b89 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/RepeatLatchPulseActivator.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.impl.action.Accumulator; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; + +import java.util.List; + +/// Emits a pulse immediately when the source latch becomes active, then repeats while held. +/// initialDelayMs delays the first repeat, not the initial pulse. +/// Do not combine with a press-pulse activator unless two initial pulses are intended. +public class RepeatLatchPulseActivator implements Activator { + + private final Activator latchActivator; + private final long initialDelayNs, repeatDelayNs; + private final HoldRepeatAccumulator holdRepeatAccumulator; + + private boolean down; + private long nextDue; + + public RepeatLatchPulseActivator( + Activator latchActivator, + long initialDelayNs, long repeatDelayNs + ) { + if (initialDelayNs < 0 || repeatDelayNs <= 0) { + throw new IllegalArgumentException("Repeat delays must be positive and initial delay nonnegative"); + } + this.latchActivator = latchActivator; + this.initialDelayNs = initialDelayNs; + this.repeatDelayNs = repeatDelayNs; + this.holdRepeatAccumulator = new HoldRepeatAccumulator(); + } + + @Override + public void onSignal(LogicalSignal signal, Accumulator acc) { + this.holdRepeatAccumulator.target = acc; + this.holdRepeatAccumulator.time = signal.timeNanos(); + + this.latchActivator.onSignal(signal, this.holdRepeatAccumulator); + + if (signal instanceof LogicalSignal.Frame) { + if (down && signal.timeNanos() >= nextDue) { + acc.firePulse(); + // Skip missed repeats after a hitch while preserving the repeat cadence. + nextDue = signal.timeNanos() + repeatDelayNs - (signal.timeNanos() - nextDue) % repeatDelayNs; + } + } + } + + @Override + public String describe() { + return "HoldRepeat[" + latchActivator.describe() + ", initialDelay=" + initialDelayNs + "ns, repeatDelay=" + repeatDelayNs + "ns]"; + } + + private class HoldRepeatAccumulator implements Accumulator { + private Accumulator target; + private long time; + + @Override + public void setLatch(boolean active) { + if (active == down) { + return; + } + if (active) { + down = true; + nextDue = time + initialDelayNs; + target.firePulse(); + } else { + down = false; + nextDue = Long.MAX_VALUE; + } + } + + @Override + public void toggleLatch() { + this.setLatch(!down); + } + } + + public record Config( + ActivatorConfig latchBinding, + int initialDelayMs, int repeatDelayMs + ) implements ActivatorConfig { + public static final String TYPE_ID = "pulse_repeat_latch"; + public static final MapCodec MAP_CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + ActivatorConfig.CODEC.fieldOf("source_latch").forGetter(Config::latchBinding), + Codec.intRange(0, Integer.MAX_VALUE).optionalFieldOf("initial_delay_ms", 250).forGetter(Config::initialDelayMs), + Codec.intRange(1, Integer.MAX_VALUE).optionalFieldOf("repeat_delay_ms", 50).forGetter(Config::repeatDelayMs) + ).apply(instance, Config::new)); + + @Override + public RepeatLatchPulseActivator create(List bindings) { + long initialDelayNs = this.initialDelayMs * 1_000_000L; + long repeatDelayNs = this.repeatDelayMs * 1_000_000L; + return new RepeatLatchPulseActivator(this.latchBinding.create(bindings), initialDelayNs, repeatDelayNs); + } + + @Override + public List leaves() { + return this.latchBinding.leaves(); + } + + @Override + public ActivatorConfigType type() { + return ActivatorConfigType.REPEAT_LATCH_PULSE; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/SpanLeafConfig.java b/src/main/java/dev/isxander/controlify/impl/action/activator/SpanLeafConfig.java new file mode 100644 index 000000000..33a3c9c5a --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/SpanLeafConfig.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.api.action.Gesture; + +import java.util.Optional; + +/// Holds the latch for the duration of a span gesture, and by default pulses once when it starts. +public record SpanLeafConfig(Gesture spanGesture, boolean editable, boolean pulseOnStart) implements LeafConfig { + public static final String TYPE_ID = "span"; + public static final SpanLeafConfig DEFAULT = new SpanLeafConfig(Gesture.WHILE_PRESSED, false, true); + public static final MapCodec MAP_CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + Gesture.CODEC.optionalFieldOf("gesture", Gesture.WHILE_PRESSED).forGetter(SpanLeafConfig::spanGesture), + Codec.BOOL.optionalFieldOf("editable", false).forGetter(SpanLeafConfig::editable), + Codec.BOOL.optionalFieldOf("pulse_on_start", true).forGetter(SpanLeafConfig::pulseOnStart) + ).apply(instance, SpanLeafConfig::new)).validate(config -> config.spanGesture().kind() == Gesture.Kind.SPAN + ? DataResult.success(config) + : DataResult.error(() -> "A span activator needs a span gesture, not " + config.spanGesture().getSerializedName())); + + @Override + public Kind kind() { + return Kind.SPAN; + } + + @Override + public Optional gesture() { + return Optional.of(this.spanGesture); + } + + @Override + public ActivatorConfigType type() { + return ActivatorConfigType.SPAN; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/activator/ToggleLatchActivator.java b/src/main/java/dev/isxander/controlify/impl/action/activator/ToggleLatchActivator.java new file mode 100644 index 000000000..aeac6adfb --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/activator/ToggleLatchActivator.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.activator; + +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.impl.action.Accumulator; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; + +import java.util.List; + +/// Accepts another pulse binding, and converts pulses into toggling a latch. +public class ToggleLatchActivator implements Activator { + + private final Activator pulseActivator; + private final PulseToToggleAcc pulseToToggleAcc; + + public ToggleLatchActivator(Activator pulseActivator) { + this.pulseActivator = pulseActivator; + this.pulseToToggleAcc = new PulseToToggleAcc(); + } + + @Override + public void onSignal(LogicalSignal signal, Accumulator acc) { + this.pulseToToggleAcc.target = acc; + this.pulseActivator.onSignal(signal, this.pulseToToggleAcc); + + // The dispatcher only cancels inputs bound to this action, so any cancel ends the toggle. + if (signal instanceof LogicalSignal.Cancelled) { + acc.setLatch(false); + } + } + + @Override + public String describe() { + return "Toggle[" + this.pulseActivator.describe() + "]"; + } + + private static final class PulseToToggleAcc implements Accumulator { + private Accumulator target; + + @Override + public void firePulse() { + target.toggleLatch(); + } + } + + public record Config( + ActivatorConfig pulseBinding + ) implements ActivatorConfig { + public static final String TYPE_ID = "latch_toggle"; + public static final MapCodec MAP_CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + ActivatorConfig.CODEC.fieldOf("source_pulse").forGetter(Config::pulseBinding) + ).apply(instance, Config::new)); + + @Override + public ToggleLatchActivator create(List bindings) { + return new ToggleLatchActivator(this.pulseBinding.create(bindings)); + } + + @Override + public List leaves() { + return this.pulseBinding.leaves(); + } + + @Override + public ActivatorConfigType type() { + return ActivatorConfigType.TOGGLE_LATCH; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/bindings/defaults/DefaultBindManager.java b/src/main/java/dev/isxander/controlify/impl/action/defaults/DefaultBindManager.java similarity index 75% rename from src/main/java/dev/isxander/controlify/bindings/defaults/DefaultBindManager.java rename to src/main/java/dev/isxander/controlify/impl/action/defaults/DefaultBindManager.java index 38af1cf98..ff879696f 100644 --- a/src/main/java/dev/isxander/controlify/bindings/defaults/DefaultBindManager.java +++ b/src/main/java/dev/isxander/controlify/impl/action/defaults/DefaultBindManager.java @@ -4,18 +4,20 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.bindings.defaults; +package dev.isxander.controlify.impl.action.defaults; import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.mojang.datafixers.util.Pair; import com.mojang.logging.LogUtils; import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; import com.mojang.serialization.JsonOps; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.FileToIdConverter; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.Resource; @@ -31,7 +33,7 @@ public class DefaultBindManager implements SimpleControlifyReloadListener { - public static final String DIRECTORY = "controllers/default_bind"; + public static final String DIRECTORY = "controller/default_bind"; private static final FileToIdConverter converter = FileToIdConverter.json(DIRECTORY); private static final Logger LOGGER = LogUtils.getLogger(); @@ -45,7 +47,7 @@ public class DefaultBindManager implements SimpleControlifyReloadListener defaultsByNamespace = new HashMap<>(); - Identifier defaultNamespaceFile = converter.idToFile(ControllerType.DEFAULT.namespace()); + Identifier defaultNamespaceFile = converter.idToFile(ControllerType.DEFAULT.id()); if (!defaultFiles.containsKey(defaultNamespaceFile)) { LOGGER.error("No default binds found! Everything will be unbound!"); return null; @@ -55,18 +57,19 @@ public class DefaultBindManager implements SimpleControlifyReloadListener> stack : defaultFiles.entrySet()) { Identifier id = stack.getKey(); List files = stack.getValue(); - if (id.equals(defaultNamespaceFile)) + if (id.equals(defaultNamespaceFile)) { continue; // already processed + } Pair> defaults = this.readDefaults(id, files); - // add the default namespace to the bottom + // add the default id to the bottom defaults.getSecond().add( new LayeredDefaultBindProvider.Layer(defaultNamespaceDefaults, false) ); @@ -85,10 +88,18 @@ private Pair> readDefaults(Id for (Resource resource : files) { try (BufferedReader reader = resource.openAsReader()) { JsonElement json = JsonParser.parseReader(reader); - ControllerDefault def = ControllerDefault.CODEC.parse(JsonOps.INSTANCE, json).result().orElseThrow(); + DataResult data = ControllerDefault.CODEC.parse(JsonOps.INSTANCE, json); + + var error = data.error(); + if (error.isPresent()) { + LOGGER.error("Failed to parse for id {}: {}", id, error.get().message()); + continue; + } + + ControllerDefault def = data.getOrThrow(); // add to top takes priority - defaults.add(0, new LayeredDefaultBindProvider.Layer(def.provider(), def.clearBelow())); + defaults.addFirst(new LayeredDefaultBindProvider.Layer(def.provider(), def.clearBelow())); } catch (IOException | IllegalStateException e) { LOGGER.error("Failed to parse {}", id, e); } @@ -107,17 +118,23 @@ public CompletableFuture apply(@Nullable Preparations data, ResourceManage if (data != null) { this.defaultsByNamespace.putAll(data.map()); } - // No binding updates here: when keepDefaultBindings is true, defaults are serialized - // and must not be changed by default provider reloads. + // Refresh action defaults from the new providers without rewriting saved bindings. + Controlify.instance().getControllerManager().ifPresent(controllerManager -> { + for (var controller : controllerManager.getAllControllers()) { + controller.input().reloadDefaultInputs(); + } + }); }, executor); } public DefaultBindProvider getDefaultBindProvider(Identifier namespace) { DefaultBindProvider provider = this.defaultsByNamespace.get(namespace); - if (provider == null) - provider = this.defaultsByNamespace.get(ControllerType.DEFAULT.namespace()); - if (provider == null) + if (provider == null) { + provider = this.defaultsByNamespace.get(ControllerType.DEFAULT.id()); + } + if (provider == null) { provider = DefaultBindProvider.EMPTY; + } return provider; } diff --git a/src/main/java/dev/isxander/controlify/impl/action/defaults/DefaultBindProvider.java b/src/main/java/dev/isxander/controlify/impl/action/defaults/DefaultBindProvider.java new file mode 100644 index 000000000..0d455017b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/defaults/DefaultBindProvider.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.defaults; + +import dev.isxander.controlify.api.action.ActionBinding; +import net.minecraft.resources.Identifier; + +import java.util.List; + +public interface DefaultBindProvider { + /// The default bindings for an action; empty when it is unbound by default. + List getDefaultBindings(Identifier actionId); + + /// Whether this provider defines bindings, including an explicit unbinding. + default boolean hasDefaultBindings(Identifier actionId) { + return !this.getDefaultBindings(actionId).isEmpty(); + } + + DefaultBindProvider EMPTY = _ -> List.of(); +} diff --git a/src/main/java/dev/isxander/controlify/bindings/defaults/LayeredDefaultBindProvider.java b/src/main/java/dev/isxander/controlify/impl/action/defaults/LayeredDefaultBindProvider.java similarity index 67% rename from src/main/java/dev/isxander/controlify/bindings/defaults/LayeredDefaultBindProvider.java rename to src/main/java/dev/isxander/controlify/impl/action/defaults/LayeredDefaultBindProvider.java index 9271dbd06..bc122acd0 100644 --- a/src/main/java/dev/isxander/controlify/bindings/defaults/LayeredDefaultBindProvider.java +++ b/src/main/java/dev/isxander/controlify/impl/action/defaults/LayeredDefaultBindProvider.java @@ -4,13 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.bindings.defaults; +package dev.isxander.controlify.impl.action.defaults; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.bindings.input.Input; +import dev.isxander.controlify.api.action.ActionBinding; import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.Nullable; import java.util.Arrays; import java.util.List; @@ -24,13 +23,17 @@ public static DefaultBindProvider of(Layer... layers) { } @Override - @Nullable public Input getDefaultBind(Identifier bindId) { + public List getDefaultBindings(Identifier actionId) { for (var layer : layers()) { - Input input = layer.provider().getDefaultBind(bindId); - if (input != null) return input; - if (layer.clearBelow()) return null; + if (layer.provider().hasDefaultBindings(actionId)) return layer.provider().getDefaultBindings(actionId); + if (layer.clearBelow()) return List.of(); } - return null; + return List.of(); + } + + @Override + public boolean hasDefaultBindings(Identifier actionId) { + return this.layers.stream().anyMatch(layer -> layer.provider().hasDefaultBindings(actionId) || layer.clearBelow()); } public record Layer(DefaultBindProvider provider, boolean clearBelow) { diff --git a/src/main/java/dev/isxander/controlify/impl/action/defaults/MapBackedDefaultBindProvider.java b/src/main/java/dev/isxander/controlify/impl/action/defaults/MapBackedDefaultBindProvider.java new file mode 100644 index 000000000..2b4177e76 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/defaults/MapBackedDefaultBindProvider.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.defaults; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.Keyable; +import com.mojang.serialization.MapCodec; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.impl.action.ActionCodecs; +import dev.isxander.controlify.impl.action.ActionSpecRegistryImpl; +import net.minecraft.resources.Identifier; + +import java.util.Map; +import java.util.List; + +public record MapBackedDefaultBindProvider(Map> map) implements DefaultBindProvider { + public static final MapCodec MAP_CODEC = Codec.simpleMap( + Identifier.CODEC, ActionCodecs.BINDING.listOf(), + Keyable.forStrings(() -> ActionSpecRegistryImpl.INSTANCE.getActionSpecs().stream().map(spec -> spec.id().toString())) + ).xmap(MapBackedDefaultBindProvider::new, MapBackedDefaultBindProvider::map); + + @Override + public List getDefaultBindings(Identifier actionId) { + return this.map.getOrDefault(actionId, List.of()); + } + + @Override + public boolean hasDefaultBindings(Identifier actionId) { + return this.map.containsKey(actionId); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/proxy/ActionProxyManagerImpl.java b/src/main/java/dev/isxander/controlify/impl/action/proxy/ActionProxyManagerImpl.java new file mode 100644 index 000000000..4c9c87597 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/proxy/ActionProxyManagerImpl.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.proxy; + +import dev.isxander.controlify.api.action.ActionProxyManager; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.impl.action.ActionAccessorImpl; +import dev.isxander.controlify.impl.action.ActionSpecRegistryImpl; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.utils.event.EventBus; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +public final class ActionProxyManagerImpl implements ActionProxyManager { + /// Subscriptions do not initialize the proxy manager singleton. + public static final class Events { + public static final EventBus REGISTER_PROXIES = new EventBus<>(); + + private Events() { + } + } + + public static final ActionProxyManagerImpl INSTANCE = new ActionProxyManagerImpl(); + + private final Set excludedKeyMappings = new HashSet<>(); + private final Map proxiedKeyMappings = new HashMap<>(); + private final Map keyMappingProxies = new LinkedHashMap<>(); + private final EventBus proxyUpdates = new EventBus<>(); + private boolean locked; + + private ActionProxyManagerImpl() { + } + + @Override + public void dontAutoProxyKeyMapping(@NonNull KeyMapping keyMapping) { + this.checkRegistrationOpen(); + this.excludedKeyMappings.add(Objects.requireNonNull(keyMapping)); + } + + @Override + public void proxyKeyMapping(@NonNull Identifier actionId, @NonNull KeyMapping keyMapping, @NonNull Predicate toggleCondition) { + this.checkRegistrationOpen(); + Objects.requireNonNull(actionId); + Objects.requireNonNull(keyMapping); + Objects.requireNonNull(toggleCondition); + if (!ActionSpecRegistryImpl.INSTANCE.isRegistered(actionId)) { + throw new IllegalArgumentException("Action is not registered: " + actionId); + } + Identifier previousAction = this.proxiedKeyMappings.get(keyMapping); + if (previousAction != null && !previousAction.equals(actionId)) { + throw new IllegalArgumentException("Key mapping is already proxied by " + previousAction); + } + KeyMappingProxy proxy = this.keyMappingProxies.computeIfAbsent(actionId, id -> { + KeyMappingProxy listener = new KeyMappingProxy(new ActionAccessorImpl(id)); + this.proxyUpdates.subscribe(listener); + return listener; + }); + proxy.addKeyMapping(keyMapping, toggleCondition); + this.proxiedKeyMappings.put(keyMapping, actionId); + } + + public boolean shouldAutomaticallyProxyKeyMapping(KeyMapping keyMapping) { + return !this.excludedKeyMappings.contains(keyMapping) && !this.proxiedKeyMappings.containsKey(keyMapping); + } + + public boolean isKeyMappingProxy(Identifier actionId) { + return this.keyMappingProxies.containsKey(actionId); + } + + public void update(ControllerImpl controller) { + this.proxyUpdates.emit(controller); + } + + public void release(ControllerImpl controller) { + for (KeyMappingProxy proxy : this.keyMappingProxies.values()) { + proxy.release(controller); + } + } + + public void lock() { + this.locked = true; + } + + private void checkRegistrationOpen() { + if (this.locked) { + throw new IllegalStateException("Action proxy registration has closed"); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/proxy/KeyMappingProxy.java b/src/main/java/dev/isxander/controlify/impl/action/proxy/KeyMappingProxy.java new file mode 100644 index 000000000..018a4e986 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/proxy/KeyMappingProxy.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.proxy; + +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.KeyMappingHandle; +import dev.isxander.controlify.impl.action.ActionImpl; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import net.minecraft.client.KeyMapping; +import net.minecraft.client.Minecraft; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Predicate; + +/// Consumes an action's pulses and forwards its held state to explicitly registered key mappings. +final class KeyMappingProxy implements EventSink { + private final ActionAccessor action; + private final Map keyMappings = new LinkedHashMap<>(); + private @Nullable ControllerImpl owner; + private @Nullable Action ownedAction; + private long actionResetVersion; + + KeyMappingProxy(ActionAccessor action) { + this.action = action; + } + + void addKeyMapping(KeyMapping keyMapping, Predicate toggleCondition) { + this.keyMappings.put(keyMapping, new Target(toggleCondition, new KeyMappingProxyState())); + } + + @Override + public void accept(@NonNull ControllerImpl controller) { + Action resolvedAction = this.action.onOrNull(controller); + Minecraft minecraft = Minecraft.getInstance(); + boolean enabled = controller.isActive() + && Controlify.instance().currentInputMode().isController() + && (minecraft.isWindowActive() || Controlify.instance().config().getSettings().globalSettings().outOfFocusInput); + if (!enabled || resolvedAction == null) { + if (resolvedAction != null) { + resolvedAction.pulse().consumeAllPulses(); + } + this.release(controller); + return; + } + long resetVersion = ((ActionImpl) resolvedAction).state().resetVersion(); + if (this.owner != null && (this.owner != controller || this.ownedAction != resolvedAction + || this.actionResetVersion != resetVersion)) { + this.release(this.owner); + } + this.owner = controller; + this.ownedAction = resolvedAction; + this.actionResetVersion = resetVersion; + + int pulses = 0; + while (resolvedAction.pulse().consumePulse()) { + pulses++; + for (KeyMapping keyMapping : this.keyMappings.keySet()) { + ((KeyMappingHandle) keyMapping).controlify$click(); + } + } + for (var entry : this.keyMappings.entrySet()) { + var state = entry.getValue().state(); + boolean wasDown = state.isDown(); + state.update(entry.getValue().toggleCondition().test(controller), resolvedAction.latch().isLatchActive(), pulses); + if (wasDown || state.isDown()) { + ((KeyMappingHandle) entry.getKey()).controlify$setDown(state.isDown()); + } + } + } + + void release(ControllerImpl controller) { + if (this.owner != controller) { + return; + } + for (var entry : this.keyMappings.entrySet()) { + if (entry.getValue().state().isDown()) { + ((KeyMappingHandle) entry.getKey()).controlify$setDown(false); + } + entry.getValue().state().reset(); + } + this.owner = null; + this.ownedAction = null; + } + + private record Target(Predicate toggleCondition, KeyMappingProxyState state) { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/action/proxy/KeyMappingProxyState.java b/src/main/java/dev/isxander/controlify/impl/action/proxy/KeyMappingProxyState.java new file mode 100644 index 000000000..6bc3dfdab --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/action/proxy/KeyMappingProxyState.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.action.proxy; + +/// Held state for one key target, independent of the source action's latch. +final class KeyMappingProxyState { + private boolean down; + private boolean toggleMode; + + boolean isDown() { + return this.down; + } + + void update(boolean toggleMode, boolean sourceDown, int pulses) { + if (!toggleMode) { + this.down = sourceDown; + } else { + if (!this.toggleMode) { + this.down = false; + } + if ((pulses & 1) != 0) { + this.down = !this.down; + } + } + this.toggleMode = toggleMode; + } + + void reset() { + this.down = false; + this.toggleMode = false; + } +} diff --git a/src/main/java/dev/isxander/controlify/fixes/boatfix/AnalogBoatInput.java b/src/main/java/dev/isxander/controlify/impl/boatfix/AnalogBoatInput.java similarity index 87% rename from src/main/java/dev/isxander/controlify/fixes/boatfix/AnalogBoatInput.java rename to src/main/java/dev/isxander/controlify/impl/boatfix/AnalogBoatInput.java index 137a98149..e9761bc6c 100644 --- a/src/main/java/dev/isxander/controlify/fixes/boatfix/AnalogBoatInput.java +++ b/src/main/java/dev/isxander/controlify/impl/boatfix/AnalogBoatInput.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.fixes.boatfix; +package dev.isxander.controlify.impl.boatfix; import dev.isxander.controlify.mixins.feature.patches.analogueboat.AbstractBoatMixin; diff --git a/src/main/java/dev/isxander/controlify/config/ConfigManager.java b/src/main/java/dev/isxander/controlify/impl/config/ConfigManager.java similarity index 96% rename from src/main/java/dev/isxander/controlify/config/ConfigManager.java rename to src/main/java/dev/isxander/controlify/impl/config/ConfigManager.java index f510bc58e..da5f03f49 100644 --- a/src/main/java/dev/isxander/controlify/config/ConfigManager.java +++ b/src/main/java/dev/isxander/controlify/impl/config/ConfigManager.java @@ -4,18 +4,18 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config; +package dev.isxander.controlify.impl.config; import com.google.gson.*; import com.mojang.logging.LogUtils; import com.mojang.serialization.Codec; import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.config.dto.SharedConfig; -import dev.isxander.controlify.config.dto.dfu.ControlifyDataFixer; -import dev.isxander.controlify.config.dto.profile.ProfileConfig; -import dev.isxander.controlify.config.settings.ControlifySettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.debug.DebugProperties; +import dev.isxander.controlify.impl.config.dto.SharedConfig; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyDataFixer; +import dev.isxander.controlify.impl.config.dto.profile.ProfileConfig; +import dev.isxander.controlify.impl.config.settings.ControlifySettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.debug.DebugProperties; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; @@ -431,7 +431,8 @@ private void makeBackup(Path path) throws IOException { do { backup = path.resolveSibling(path.getFileName() + ".backup" + (index == 0 ? "" : index)); index++; - } while (Files.exists(backup)); + } + while (Files.exists(backup)); Files.copy(path, backup); } @@ -441,7 +442,8 @@ private void archiveLegacy() throws IOException { do { backup = legacyPath.resolveSibling(legacyPath.getFileName() + ".backup" + (index == 0 ? "" : index)); index++; - } while (Files.exists(backup)); + } + while (Files.exists(backup)); Files.move(legacyPath, backup); } diff --git a/src/main/java/dev/isxander/controlify/config/ConfigMigrator.java b/src/main/java/dev/isxander/controlify/impl/config/ConfigMigrator.java similarity index 92% rename from src/main/java/dev/isxander/controlify/config/ConfigMigrator.java rename to src/main/java/dev/isxander/controlify/impl/config/ConfigMigrator.java index f45313fee..3db68f6c1 100644 --- a/src/main/java/dev/isxander/controlify/config/ConfigMigrator.java +++ b/src/main/java/dev/isxander/controlify/impl/config/ConfigMigrator.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config; +package dev.isxander.controlify.impl.config; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -15,13 +15,13 @@ import com.mojang.serialization.DataResult; import com.mojang.serialization.Dynamic; import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.config.dto.SharedConfig; -import dev.isxander.controlify.config.dto.dfu.ControlifyDataFixer; -import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; -import dev.isxander.controlify.config.dto.profile.ProfileConfig; -import dev.isxander.controlify.config.dto.profile.defaults.DefaultConfigManager; -import dev.isxander.controlify.config.settings.ControlifySettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.config.dto.SharedConfig; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyDataFixer; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.dto.profile.ProfileConfig; +import dev.isxander.controlify.impl.config.dto.profile.defaults.DefaultConfigManager; +import dev.isxander.controlify.impl.config.settings.ControlifySettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; import java.io.IOException; import java.util.ArrayList; diff --git a/src/main/java/dev/isxander/controlify/config/dto/GlobalConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/GlobalConfig.java similarity index 94% rename from src/main/java/dev/isxander/controlify/config/dto/GlobalConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/GlobalConfig.java index c4766d70c..5fece7c93 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/GlobalConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/GlobalConfig.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto; +package dev.isxander.controlify.impl.config.dto; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.reacharound.ReachAroundMode; +import dev.isxander.controlify.impl.reacharound.ReachAroundMode; import java.util.List; diff --git a/src/main/java/dev/isxander/controlify/config/dto/SharedConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/SharedConfig.java similarity index 85% rename from src/main/java/dev/isxander/controlify/config/dto/SharedConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/SharedConfig.java index 563556398..9aaa985c3 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/SharedConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/SharedConfig.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto; +package dev.isxander.controlify.impl.config.dto; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.config.dto.device.DeviceConfig; +import dev.isxander.controlify.impl.config.dto.device.DeviceConfig; import java.util.Map; diff --git a/src/main/java/dev/isxander/controlify/config/dto/device/DeviceConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/device/DeviceConfig.java similarity index 79% rename from src/main/java/dev/isxander/controlify/config/dto/device/DeviceConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/device/DeviceConfig.java index f0c7bb87f..c74a64bf8 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/device/DeviceConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/device/DeviceConfig.java @@ -4,12 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.device; +package dev.isxander.controlify.impl.config.dto.device; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.controller.input.mapping.ControllerMapping; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.input.mapping.ControllerMapping; import net.minecraft.resources.Identifier; import java.util.Optional; @@ -24,7 +24,7 @@ public record DeviceConfig( public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.STRING.optionalFieldOf("name", "").forGetter(DeviceConfig::name), Codec.LONG.optionalFieldOf("last_seen", 0L).forGetter(DeviceConfig::lastSeen), - Identifier.CODEC.optionalFieldOf("controller_type", ControllerType.DEFAULT.namespace()).forGetter(DeviceConfig::controllerType), + Identifier.CODEC.optionalFieldOf("controller_type", ControllerType.DEFAULT.id()).forGetter(DeviceConfig::controllerType), GyroCalibrationConfig.CODEC.fieldOf("gyro_calibration").forGetter(DeviceConfig::gyroCalibration), ControllerMapping.CODEC.optionalFieldOf("mapping").forGetter(DeviceConfig::mapping) ).apply(instance, DeviceConfig::new)); diff --git a/src/main/java/dev/isxander/controlify/config/dto/device/GyroCalibrationConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/device/GyroCalibrationConfig.java similarity index 60% rename from src/main/java/dev/isxander/controlify/config/dto/device/GyroCalibrationConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/device/GyroCalibrationConfig.java index 409c71998..fdd059f18 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/device/GyroCalibrationConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/device/GyroCalibrationConfig.java @@ -4,16 +4,18 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.device; +package dev.isxander.controlify.impl.config.dto.device; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.gyro.GyroStateC; +import net.minecraft.util.ExtraCodecs; +import org.joml.Vector3f; +import org.joml.Vector3fc; public record GyroCalibrationConfig( - GyroStateC offset + Vector3fc offset ) { public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - GyroStateC.CODEC_MUTABLE.optionalFieldOf("offset", GyroStateC.ZERO).forGetter(GyroCalibrationConfig::offset) + ExtraCodecs.VECTOR3F.optionalFieldOf("offset", new Vector3f()).forGetter(GyroCalibrationConfig::offset) ).apply(instance, GyroCalibrationConfig::new)); } diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifyDataFixer.java similarity index 65% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifyDataFixer.java index f67b2f46d..ff83ebdab 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifyDataFixer.java @@ -4,16 +4,16 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu; +package dev.isxander.controlify.impl.config.dto.dfu; import com.mojang.datafixers.DataFixer; import com.mojang.datafixers.DataFixerBuilder; -import dev.isxander.controlify.config.dto.dfu.fixes.*; -import dev.isxander.controlify.config.settings.GlobalSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.config.dto.dfu.fixes.*; +import dev.isxander.controlify.impl.config.settings.GlobalSettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; public final class ControlifyDataFixer { - public static final int CURRENT_VERSION = 8; + public static final int CURRENT_VERSION = 13; public static DataFixer getFixer() { return ProductionFixerHolder.FIXER; @@ -31,30 +31,43 @@ private static DataFixer createFixer(GlobalSettings globalDefaults, ProfileSetti var builder = new DataFixerBuilder(CURRENT_VERSION); var v0 = builder.addSchema(0, ControlifySchemas.V0::new); - var v1 = builder.addSchema(1, ControlifySchemas.V1::new); - var v2 = builder.addSchema(2, ControlifySchemas.V2::new); - var v3 = builder.addSchema(3, ControlifySchemas.V3::new); - var v6 = builder.addSchema(6, ControlifySchemas.V6::new); - var v7 = builder.addSchema(7, ControlifySchemas.V7::new); - var v8 = builder.addSchema(8, ControlifySchemas.V8::new); - // v1 + var v1 = builder.addSchema(1, ControlifySchemas.V1::new); builder.addFixer(new TheHolyMigrationFix(v1, globalDefaults, profileDefaults)); - // v2 + var v2 = builder.addSchema(2, ControlifySchemas.V2::new); builder.addFixer(new AnalogueMovementWhitelistFix(v2)); builder.addFixer(new HorizontalLookInvertFix(v2)); - // v6 + var v3 = builder.addSchema(3, ControlifySchemas.V3::new); + var v6 = builder.addSchema(6, ControlifySchemas.V6::new); builder.addFixer(new DualsenseConfigFix(v6, profileDefaults)); // v7 + var v7 = builder.addSchema(7, ControlifySchemas.V7::new); builder.addFixer(new GuideGuiScaleFix(v7, profileDefaults)); // v8 + var v8 = builder.addSchema(8, ControlifySchemas.V8::new); builder.addFixer(new GeneratedBindingIdsFix(v8)); builder.addFixer(new RadialMenuActionsFix(v8)); + var v9 = builder.addSchema(9, ControlifySchemas.V9::new); + builder.addFixer(new GyroLowPassFix(v9, profileDefaults)); + + var v10 = builder.addSchema(10, ControlifySchemas.V10::new); + builder.addFixer(new RemoveRelativeGyroFix(v10)); + + var v11 = builder.addSchema(11, ControlifySchemas.V11::new); + builder.addFixer(new LogicalInputBindingsFix(v11)); + + var v12 = builder.addSchema(12, ControlifySchemas.V12::new); + builder.addFixer(new DeviceInputFormatFix(v12)); + + var v13 = builder.addSchema(13, ControlifySchemas.V13::new); + builder.addFixer(new GestureTimingsFix(v13, profileDefaults)); + builder.addFixer(new DeadzoneDimensionsFix(v13)); + return builder.build().fixer(); } diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifySchemas.java similarity index 75% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifySchemas.java index 173c5b123..d63c8ac0e 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifySchemas.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu; +package dev.isxander.controlify.impl.config.dto.dfu; import com.mojang.datafixers.DSL; import com.mojang.datafixers.schemas.Schema; @@ -88,4 +88,35 @@ public V8(int versionKey, Schema parent) { super(versionKey, parent); } } + + public static class V9 extends Schema { + public V9(int versionKey, Schema parent) { + super(versionKey, parent); + } + } + + public static class V10 extends Schema { + public V10(int versionKey, Schema parent) { + super(versionKey, parent); + } + } + + public static class V11 extends Schema { + public V11(int versionKey, Schema parent) { + super(versionKey, parent); + } + } + + public static class V12 extends Schema { + public V12(int versionKey, Schema parent) { + super(versionKey, parent); + } + } + + public static class V13 extends Schema { + public V13(int versionKey, Schema parent) { + super(versionKey, parent); + } + } + } diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyTypeReferences.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifyTypeReferences.java similarity index 93% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyTypeReferences.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifyTypeReferences.java index 5003c4a2d..69987e79a 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyTypeReferences.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/ControlifyTypeReferences.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu; +package dev.isxander.controlify.impl.config.dto.dfu; import com.mojang.datafixers.DSL; diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/AnalogueMovementWhitelistFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/AnalogueMovementWhitelistFix.java similarity index 89% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/AnalogueMovementWhitelistFix.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/AnalogueMovementWhitelistFix.java index dd786fae9..e658c2ae6 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/AnalogueMovementWhitelistFix.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/AnalogueMovementWhitelistFix.java @@ -4,14 +4,14 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu.fixes; +package dev.isxander.controlify.impl.config.dto.dfu.fixes; import com.mojang.datafixers.DSL; import com.mojang.datafixers.DataFix; import com.mojang.datafixers.TypeRewriteRule; import com.mojang.datafixers.schemas.Schema; import com.mojang.serialization.Dynamic; -import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; import java.util.stream.Stream; diff --git a/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DeadzoneDimensionsFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DeadzoneDimensionsFix.java new file mode 100644 index 000000000..ba4429154 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DeadzoneDimensionsFix.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.config.dto.dfu.fixes; + +import com.mojang.datafixers.DSL; +import com.mojang.datafixers.DataFix; +import com.mojang.datafixers.TypeRewriteRule; +import com.mojang.datafixers.schemas.Schema; +import com.mojang.serialization.Dynamic; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; + +import java.util.ArrayList; +import java.util.List; + +/// Deadzone groups changed from a flat axis list to opposing half-axis pairs. +/// Flat lists were conventionally ordered positive then negative for each dimension, +/// so consecutive axes become a dimension; a trailing odd axis becomes a one-sided dimension. +public final class DeadzoneDimensionsFix extends DataFix { + public DeadzoneDimensionsFix(Schema outputSchema) { + super(outputSchema, true); + } + + @Override + protected TypeRewriteRule makeRule() { + return fixTypeEverywhereTyped( + "Controlify: pair device mapping deadzone axes into dimensions", + getInputSchema().getType(ControlifyTypeReferences.SHARED_CONFIG), + typed -> typed.update(DSL.remainderFinder(), root -> root.update("devices", devices -> + devices.updateMapValues(entry -> entry.mapSecond(device -> + device.update("mapping", mapping -> + mapping.update("deadzones", deadzones -> + deadzones.updateMapValues(group -> group.mapSecond(this::rewriteGroup)))))))) + ); + } + + private Dynamic rewriteGroup(Dynamic group) { + var axes = group.get("axes").asList(axis -> axis); + if (axes.isEmpty()) { + return group; + } + + List> dimensions = new ArrayList<>(); + for (int i = 0; i < axes.size(); i += 2) { + var dimension = group.emptyMap().set("positive", axes.get(i)); + if (i + 1 < axes.size()) { + dimension = dimension.set("negative", axes.get(i + 1)); + } + dimensions.add(dimension); + } + + return group.remove("axes").set("dimensions", group.createList(dimensions.stream())); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DeviceInputFormatFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DeviceInputFormatFix.java new file mode 100644 index 000000000..347820bd3 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DeviceInputFormatFix.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.config.dto.dfu.fixes; + +import com.mojang.datafixers.DSL; +import com.mojang.datafixers.DataFix; +import com.mojang.datafixers.TypeRewriteRule; +import com.mojang.datafixers.schemas.Schema; +import com.mojang.serialization.Dynamic; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; + +import java.util.stream.Stream; + +public final class DeviceInputFormatFix extends DataFix { + public DeviceInputFormatFix(Schema outputSchema) { + super(outputSchema, true); + } + + @Override + protected TypeRewriteRule makeRule() { + return fixTypeEverywhereTyped( + "Controlify: migrate device gyro offsets and discard obsolete mappings", + getInputSchema().getType(ControlifyTypeReferences.SHARED_CONFIG), + typed -> typed.update(DSL.remainderFinder(), root -> root.update("devices", devices -> + devices.updateMapValues(entry -> entry.mapSecond(this::rewriteDevice)))) + ); + } + + private Dynamic rewriteDevice(Dynamic device) { + return device.update("gyro_calibration", calibration -> calibration.update("offset", offset -> { + if (offset.getMapValues().result().isEmpty()) return offset; + return offset.createList(Stream.of("pitch", "yaw", "roll") + .map(axis -> offset.createFloat(offset.get(axis).asFloat(0f)))); + })) + // Snapshot mappings have different semantics from the new event mappings. + // Discard the old override so the controller uses its current default mapping. + .remove("mapping"); + } +} diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/DualsenseConfigFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DualsenseConfigFix.java similarity index 85% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/DualsenseConfigFix.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DualsenseConfigFix.java index 3f11e7d53..25ae2a3f0 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/DualsenseConfigFix.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/DualsenseConfigFix.java @@ -4,15 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu.fixes; +package dev.isxander.controlify.impl.config.dto.dfu.fixes; import com.mojang.datafixers.DSL; import com.mojang.datafixers.DataFix; import com.mojang.datafixers.TypeRewriteRule; import com.mojang.datafixers.schemas.Schema; import com.mojang.serialization.Dynamic; -import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; public final class DualsenseConfigFix extends DataFix { private final ProfileSettings profileDefaults; diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GeneratedBindingIdsFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GeneratedBindingIdsFix.java similarity index 94% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GeneratedBindingIdsFix.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GeneratedBindingIdsFix.java index a37582f36..077416a9e 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GeneratedBindingIdsFix.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GeneratedBindingIdsFix.java @@ -4,14 +4,14 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu.fixes; +package dev.isxander.controlify.impl.config.dto.dfu.fixes; import com.mojang.datafixers.DSL; import com.mojang.datafixers.DataFix; import com.mojang.datafixers.TypeRewriteRule; import com.mojang.datafixers.schemas.Schema; import com.mojang.serialization.Dynamic; -import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; import java.util.LinkedHashMap; import java.util.Map; diff --git a/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GestureTimingsFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GestureTimingsFix.java new file mode 100644 index 000000000..7c0332379 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GestureTimingsFix.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.config.dto.dfu.fixes; + +import com.mojang.datafixers.DSL; +import com.mojang.datafixers.DataFix; +import com.mojang.datafixers.TypeRewriteRule; +import com.mojang.datafixers.schemas.Schema; +import com.mojang.serialization.Dynamic; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; + +public class GestureTimingsFix extends DataFix { + private final ProfileSettings profileDefaults; + + public GestureTimingsFix(Schema outputSchema, ProfileSettings profileDefaults) { + super(outputSchema, true); + this.profileDefaults = profileDefaults; + } + + @Override + protected TypeRewriteRule makeRule() { + var profileType = getInputSchema().getType(ControlifyTypeReferences.PROFILE_CONFIG); + + return fixTypeEverywhereTyped( + "Controlify: add button gesture timing settings", + profileType, + typed -> typed.update(DSL.remainderFinder(), this::rewriteProfile) + ); + } + + private Dynamic rewriteProfile(Dynamic root) { + var defaults = profileDefaults.input.gestures; + var input = root.get("input").orElseEmptyMap(); + + var gestures = root.emptyMap() + .set("tap_max_ms", root.createInt(defaults.tapMaxMs)) + .set("double_tap_gap_ms", root.createInt(defaults.doubleTapGapMs)) + .set("hold_ms", root.createInt(defaults.holdMs)); + + return root.set("input", input.set("gestures", gestures)); + } +} diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GuideGuiScaleFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GuideGuiScaleFix.java similarity index 86% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GuideGuiScaleFix.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GuideGuiScaleFix.java index ebb376807..db939e853 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GuideGuiScaleFix.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GuideGuiScaleFix.java @@ -4,15 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu.fixes; +package dev.isxander.controlify.impl.config.dto.dfu.fixes; import com.mojang.datafixers.DSL; import com.mojang.datafixers.DataFix; import com.mojang.datafixers.TypeRewriteRule; import com.mojang.datafixers.schemas.Schema; import com.mojang.serialization.Dynamic; -import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; public class GuideGuiScaleFix extends DataFix { private final ProfileSettings profileDefaults; diff --git a/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GyroLowPassFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GyroLowPassFix.java new file mode 100644 index 000000000..2fea39ff0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/GyroLowPassFix.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.config.dto.dfu.fixes; + +import com.mojang.datafixers.DSL; +import com.mojang.datafixers.DataFix; +import com.mojang.datafixers.TypeRewriteRule; +import com.mojang.datafixers.schemas.Schema; +import com.mojang.serialization.Dynamic; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; + +public class GyroLowPassFix extends DataFix { + private final ProfileSettings profileDefaults; + + public GyroLowPassFix(Schema outputSchema, ProfileSettings profileDefaults) { + super(outputSchema, true); + this.profileDefaults = profileDefaults; + } + + @Override + protected TypeRewriteRule makeRule() { + var profileType = getInputSchema().getType(ControlifyTypeReferences.PROFILE_CONFIG); + + return fixTypeEverywhereTyped( + "Controlify: add gyro low pass hz setting", + profileType, + typed -> typed.update(DSL.remainderFinder(), this::rewriteProfile) + ); + } + + private Dynamic rewriteProfile(Dynamic root) { + var gyro = root.get("gyro").orElseEmptyMap(); + + gyro = gyro.set("low_pass_cutoff_hz", root.createFloat(profileDefaults.gyro.lowPassCutoffHz)); + + return root.set("gyro", gyro); + } +} diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/HorizontalLookInvertFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/HorizontalLookInvertFix.java similarity index 89% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/HorizontalLookInvertFix.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/HorizontalLookInvertFix.java index 3eb94df99..c1ce914db 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/HorizontalLookInvertFix.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/HorizontalLookInvertFix.java @@ -4,14 +4,14 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu.fixes; +package dev.isxander.controlify.impl.config.dto.dfu.fixes; import com.mojang.datafixers.DSL; import com.mojang.datafixers.DataFix; import com.mojang.datafixers.TypeRewriteRule; import com.mojang.datafixers.schemas.Schema; import com.mojang.serialization.Dynamic; -import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; public final class HorizontalLookInvertFix extends DataFix { public HorizontalLookInvertFix(Schema outputSchema) { diff --git a/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/LogicalInputBindingsFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/LogicalInputBindingsFix.java new file mode 100644 index 000000000..b70e3d011 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/LogicalInputBindingsFix.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.config.dto.dfu.fixes; + +import com.mojang.datafixers.DSL; +import com.mojang.datafixers.DataFix; +import com.mojang.datafixers.TypeRewriteRule; +import com.mojang.datafixers.schemas.Schema; +import com.mojang.serialization.Dynamic; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; + +import java.util.Locale; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +public final class LogicalInputBindingsFix extends DataFix { + public LogicalInputBindingsFix(Schema outputSchema) { + super(outputSchema, true); + } + + @Override + protected TypeRewriteRule makeRule() { + return fixTypeEverywhereTyped( + "Controlify: migrate bindings to logical inputs", + getInputSchema().getType(ControlifyTypeReferences.PROFILE_CONFIG), + typed -> typed.update(DSL.remainderFinder(), root -> root.update("input", input -> + input.update("bindings", bindings -> bindings.updateMapValues(entry -> + entry.mapSecond(this::rewriteInput))))) + ); + } + + private Dynamic rewriteInput(Dynamic input) { + for (String field : new String[]{"button", "axis"}) { + var identifier = input.get(field).asString().result(); + if (identifier.isPresent()) { + return input.createString(identifier.get()); + } + } + var hat = input.get("hat").asString().result(); + var direction = input.get("target_state").asString().result(); + if (hat.isPresent() && direction.isPresent()) { + String name = direction.get().toLowerCase(Locale.ROOT); + var parts = Arrays.asList(name.split("_")); + var buttons = List.of("up", "right", "down", "left").stream().filter(parts::contains).toList(); + if (buttons.size() == 2) { + // Diagonals use the same cardinal button pair emitted by SDLToPhysicalStage. + return input.createMap(Map.of( + input.createString("modifiers"), input.createList(Stream.of(input.createString(hat.get() + "/" + buttons.getFirst()))), + input.createString("trigger"), input.createString(hat.get() + "/" + buttons.getLast()) + )); + } + return name.equals("centered") ? input.emptyMap() + : input.createString(hat.get() + "/" + name); + } + if (input.get("type").asString().result().filter("empty"::equals).isPresent()) { + return input.emptyMap(); + } + // Already-converted strings, chords and explicit unbindings retain their meaning. + return input; + } +} diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/RadialMenuActionsFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/RadialMenuActionsFix.java similarity index 93% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/RadialMenuActionsFix.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/RadialMenuActionsFix.java index f9cd727ba..d4b0d71f5 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/RadialMenuActionsFix.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/RadialMenuActionsFix.java @@ -4,14 +4,14 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu.fixes; +package dev.isxander.controlify.impl.config.dto.dfu.fixes; import com.mojang.datafixers.DSL; import com.mojang.datafixers.DataFix; import com.mojang.datafixers.TypeRewriteRule; import com.mojang.datafixers.schemas.Schema; import com.mojang.serialization.Dynamic; -import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; import java.util.LinkedHashSet; import java.util.List; diff --git a/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/RemoveRelativeGyroFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/RemoveRelativeGyroFix.java new file mode 100644 index 000000000..ddf5fb05f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/RemoveRelativeGyroFix.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.config.dto.dfu.fixes; + +import com.mojang.datafixers.DSL; +import com.mojang.datafixers.DataFix; +import com.mojang.datafixers.TypeRewriteRule; +import com.mojang.datafixers.schemas.Schema; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; + +public class RemoveRelativeGyroFix extends DataFix { + public RemoveRelativeGyroFix(Schema outputSchema) { + super(outputSchema, true); + } + + @Override + protected TypeRewriteRule makeRule() { + return fixTypeEverywhereTyped( + "Controlify: remove relative gyro mode", + getInputSchema().getType(ControlifyTypeReferences.PROFILE_CONFIG), + typed -> typed.update(DSL.remainderFinder(), + root -> root.update("gyro", gyro -> gyro.remove("relative_mode"))) + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/TheHolyMigrationFix.java b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/TheHolyMigrationFix.java similarity index 97% rename from src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/TheHolyMigrationFix.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/TheHolyMigrationFix.java index 093f37b97..2a1da8887 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/TheHolyMigrationFix.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/dfu/fixes/TheHolyMigrationFix.java @@ -4,16 +4,16 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.dfu.fixes; +package dev.isxander.controlify.impl.config.dto.dfu.fixes; import com.mojang.datafixers.DSL; import com.mojang.datafixers.DataFix; import com.mojang.datafixers.TypeRewriteRule; import com.mojang.datafixers.schemas.Schema; import com.mojang.serialization.Dynamic; -import dev.isxander.controlify.config.dto.dfu.ControlifyTypeReferences; -import dev.isxander.controlify.config.settings.GlobalSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.config.dto.dfu.ControlifyTypeReferences; +import dev.isxander.controlify.impl.config.settings.GlobalSettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; import java.util.Map; import java.util.Optional; @@ -235,7 +235,7 @@ private Dynamic rewriteGyroConfig(Dynamic old, Dynamic root) { root.createString("look_sensitivity"), old.get("look_sensitivity") .result().orElse(root.createFloat(defaults.lookSensitivity)), root.createString("relative_mode"), old.get("relative_gyro_mode") - .result().orElse(root.createBoolean(defaults.relativeMode)), + .result().orElse(root.createBoolean(false)), root.createString("invert_pitch"), old.get("invert_y") .result().orElse(root.createBoolean(defaults.invertPitch)), root.createString("invert_yaw"), old.get("invert_x") diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/BluetoothDeviceConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/BluetoothDeviceConfig.java similarity index 90% rename from src/main/java/dev/isxander/controlify/config/dto/profile/BluetoothDeviceConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/BluetoothDeviceConfig.java index 445b0d50c..bf838c791 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/BluetoothDeviceConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/BluetoothDeviceConfig.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile; +package dev.isxander.controlify.impl.config.dto.profile; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/DualsenseConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/DualsenseConfig.java similarity index 90% rename from src/main/java/dev/isxander/controlify/config/dto/profile/DualsenseConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/DualsenseConfig.java index 064842fd0..1e89db8f9 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/DualsenseConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/DualsenseConfig.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile; +package dev.isxander.controlify.impl.config.dto.profile; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/GenericControllerConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/GenericControllerConfig.java similarity index 97% rename from src/main/java/dev/isxander/controlify/config/dto/profile/GenericControllerConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/GenericControllerConfig.java index 7eccea863..c547a033c 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/GenericControllerConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/GenericControllerConfig.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile; +package dev.isxander.controlify.impl.config.dto.profile; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/GyroConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/GyroConfig.java similarity index 63% rename from src/main/java/dev/isxander/controlify/config/dto/profile/GyroConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/GyroConfig.java index 5c925796e..a3f207b27 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/GyroConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/GyroConfig.java @@ -4,30 +4,36 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile; +package dev.isxander.controlify.impl.config.dto.profile; import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.controller.gyro.GyroButtonMode; -import dev.isxander.controlify.controller.gyro.GyroYawMode; +import dev.isxander.controlify.impl.controller.gyro.GyroButtonMode; +import dev.isxander.controlify.impl.controller.gyro.GyroYawMode; public record GyroConfig( float lookSensitivity, - boolean relativeMode, boolean invertPitch, boolean invertYaw, GyroButtonMode buttonMode, GyroYawMode yawMode, - boolean flickStick + boolean flickStick, + float lowPassCutoffHz ) { public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( Codec.FLOAT.fieldOf("look_sensitivity").forGetter(GyroConfig::lookSensitivity), - Codec.BOOL.fieldOf("relative_mode").forGetter(GyroConfig::relativeMode), Codec.BOOL.fieldOf("invert_pitch").forGetter(GyroConfig::invertPitch), Codec.BOOL.fieldOf("invert_yaw").forGetter(GyroConfig::invertYaw), GyroButtonMode.CODEC.fieldOf("button_mode").forGetter(GyroConfig::buttonMode), GyroYawMode.CODEC.fieldOf("yaw_mode").forGetter(GyroConfig::yawMode), - Codec.BOOL.fieldOf("flick_stick").forGetter(GyroConfig::flickStick) + Codec.BOOL.fieldOf("flick_stick").forGetter(GyroConfig::flickStick), + Codec.FLOAT + .validate(f -> Float.isFinite(f) && f >= 0 + ? DataResult.success(f) + : DataResult.error(() -> "low pass cutoff hz must be finite and positive")) + .fieldOf("low_pass_cutoff_hz") + .forGetter(GyroConfig::lowPassCutoffHz) ).apply(instance, GyroConfig::new)); } diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/HDHapticConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/HDHapticConfig.java similarity index 89% rename from src/main/java/dev/isxander/controlify/config/dto/profile/HDHapticConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/HDHapticConfig.java index 5c43a0b32..df7204edb 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/HDHapticConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/HDHapticConfig.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile; +package dev.isxander.controlify.impl.config.dto.profile; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/InputConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/InputConfig.java similarity index 68% rename from src/main/java/dev/isxander/controlify/config/dto/profile/InputConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/InputConfig.java index dfab3b6ea..7a423b85f 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/InputConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/InputConfig.java @@ -4,12 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile; +package dev.isxander.controlify.impl.config.dto.profile; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.ingame.InputCurves; +import dev.isxander.controlify.impl.action.ActionCodecs; +import dev.isxander.controlify.impl.ingame.InputCurves; +import dev.isxander.controlify.api.action.ActionBinding; import net.minecraft.resources.Identifier; import java.util.List; @@ -19,6 +20,7 @@ public record InputConfig( BindingsConfig bindings, SensitivityConfig sensitivity, RadialMenuConfig radialMenu, + GestureConfig gestures, float buttonActivationThreshold, boolean keepDefaultBindings ) { @@ -26,16 +28,18 @@ public record InputConfig( BindingsConfig.CODEC.optionalFieldOf("bindings", BindingsConfig.EMPTY).forGetter(InputConfig::bindings), SensitivityConfig.CODEC.fieldOf("sensitivity").forGetter(InputConfig::sensitivity), RadialMenuConfig.CODEC.fieldOf("radial_menu").forGetter(InputConfig::radialMenu), + GestureConfig.CODEC.fieldOf("gestures").forGetter(InputConfig::gestures), Codec.floatRange(0.01f, 1f).fieldOf("button_activation_threshold").forGetter(InputConfig::buttonActivationThreshold), Codec.BOOL.fieldOf("keep_default_bindings").forGetter(InputConfig::keepDefaultBindings) ).apply(instance, InputConfig::new)); + /// Action -> its bindings, overriding the controller defaults. An empty list unbinds the action. public record BindingsConfig( - Map bindings + Map> bindings ) { public static final BindingsConfig EMPTY = new BindingsConfig(Map.of()); - public static final Codec CODEC = Codec.unboundedMap(Identifier.CODEC, Input.CODEC) + public static final Codec CODEC = Codec.unboundedMap(Identifier.CODEC, ActionCodecs.BINDING.listOf()) .xmap(BindingsConfig::new, BindingsConfig::bindings); public BindingsConfig { @@ -69,6 +73,27 @@ public record SensitivityConfig( ).apply(instance, SensitivityConfig::new)); } + /// Button gesture timings in milliseconds. + /// @param tapMaxMs the longest press that still counts as a tap + /// @param doubleTapGapMs the longest gap between releasing a tap and pressing again for a double tap + /// @param holdMs how long a press lasts before it counts as held; raised above tapMaxMs if needed, + /// since the settings screen sets each value independently + public record GestureConfig( + int tapMaxMs, + int doubleTapGapMs, + int holdMs + ) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.intRange(0, 2000).fieldOf("tap_max_ms").forGetter(GestureConfig::tapMaxMs), + Codec.intRange(0, 2000).fieldOf("double_tap_gap_ms").forGetter(GestureConfig::doubleTapGapMs), + Codec.intRange(1, 5000).fieldOf("hold_ms").forGetter(GestureConfig::holdMs) + ).apply(instance, GestureConfig::new)); + + public GestureConfig { + holdMs = Math.max(holdMs, tapMaxMs + 1); + } + } + public record RadialMenuConfig( List radialActions, int radialButtonFocusTimeoutTicks diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/ProfileConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/ProfileConfig.java similarity index 96% rename from src/main/java/dev/isxander/controlify/config/dto/profile/ProfileConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/ProfileConfig.java index 1daf893f6..e376ce2b9 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/ProfileConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/ProfileConfig.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile; +package dev.isxander.controlify.impl.config.dto.profile; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/RumbleConfig.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/RumbleConfig.java similarity index 92% rename from src/main/java/dev/isxander/controlify/config/dto/profile/RumbleConfig.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/RumbleConfig.java index 0b1b86505..7b31787e1 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/RumbleConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/RumbleConfig.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile; +package dev.isxander.controlify.impl.config.dto.profile; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/defaults/DefaultConfigManager.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/defaults/DefaultConfigManager.java similarity index 82% rename from src/main/java/dev/isxander/controlify/config/dto/profile/defaults/DefaultConfigManager.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/defaults/DefaultConfigManager.java index 9dc1a1e4c..1e78e3439 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/defaults/DefaultConfigManager.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/defaults/DefaultConfigManager.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile.defaults; +package dev.isxander.controlify.impl.config.dto.profile.defaults; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -12,9 +12,10 @@ import com.mojang.logging.LogUtils; import com.mojang.serialization.DataResult; import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.config.dto.profile.ProfileConfig; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.config.dto.profile.ProfileConfig; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.CrashReport; import net.minecraft.ReportedException; import net.minecraft.resources.Identifier; @@ -28,7 +29,7 @@ import java.util.concurrent.Executor; public class DefaultConfigManager implements SimpleControlifyReloadListener, DefaultConfigProvider { - public static final Identifier DEFAULT_RESOURCE = CUtil.rl("controllers/default_config.json"); + public static final Identifier DEFAULT_RESOURCE = CUtil.rl("controller/default_config.json"); private static final Logger LOGGER = LogUtils.getLogger(); private ProfileConfig defaultConfig; @@ -76,7 +77,14 @@ public CompletableFuture load(ResourceManager manager, Executor e @Override public CompletableFuture apply(ProfileConfig data, ResourceManager manager, Executor executor) { - return CompletableFuture.runAsync(() -> this.defaultConfig = data, executor); + return CompletableFuture.runAsync(() -> { + this.defaultConfig = data; + Controlify.instance().getControllerManager().ifPresent(controllerManager -> { + for (var controller : controllerManager.getAllControllers()) { + controller.reloadDefaultSettings(); + } + }); + }, executor); } @Override diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/defaults/DefaultConfigProvider.java b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/defaults/DefaultConfigProvider.java similarity index 61% rename from src/main/java/dev/isxander/controlify/config/dto/profile/defaults/DefaultConfigProvider.java rename to src/main/java/dev/isxander/controlify/impl/config/dto/profile/defaults/DefaultConfigProvider.java index 924c14fcf..efef27e0e 100644 --- a/src/main/java/dev/isxander/controlify/config/dto/profile/defaults/DefaultConfigProvider.java +++ b/src/main/java/dev/isxander/controlify/impl/config/dto/profile/defaults/DefaultConfigProvider.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.dto.profile.defaults; +package dev.isxander.controlify.impl.config.dto.profile.defaults; -import dev.isxander.controlify.config.dto.profile.ProfileConfig; +import dev.isxander.controlify.impl.config.dto.profile.ProfileConfig; public interface DefaultConfigProvider { boolean isReady(); diff --git a/src/main/java/dev/isxander/controlify/config/settings/ControlifySettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/ControlifySettings.java similarity index 90% rename from src/main/java/dev/isxander/controlify/config/settings/ControlifySettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/ControlifySettings.java index 612294d00..5b33fe0d9 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/ControlifySettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/ControlifySettings.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings; +package dev.isxander.controlify.impl.config.settings; -import dev.isxander.controlify.config.dto.SharedConfig; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.config.dto.SharedConfig; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; import it.unimi.dsi.fastutil.ints.Int2ObjectAVLTreeMap; import it.unimi.dsi.fastutil.ints.Int2ObjectSortedMap; import it.unimi.dsi.fastutil.ints.Int2ObjectSortedMaps; diff --git a/src/main/java/dev/isxander/controlify/config/settings/GlobalSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/GlobalSettings.java similarity index 95% rename from src/main/java/dev/isxander/controlify/config/settings/GlobalSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/GlobalSettings.java index 828c5abf9..c918fdb21 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/GlobalSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/GlobalSettings.java @@ -4,12 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings; +package dev.isxander.controlify.impl.config.settings; import com.google.common.collect.Sets; -import dev.isxander.controlify.config.dto.GlobalConfig; -import dev.isxander.controlify.reacharound.ReachAroundMode; -import dev.isxander.controlify.server.ServerPolicies; +import dev.isxander.controlify.impl.config.dto.GlobalConfig; +import dev.isxander.controlify.impl.reacharound.ReachAroundMode; +import dev.isxander.controlify.impl.server.ServerPolicies; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.multiplayer.ServerData; diff --git a/src/main/java/dev/isxander/controlify/config/settings/device/DeviceSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/device/DeviceSettings.java similarity index 78% rename from src/main/java/dev/isxander/controlify/config/settings/device/DeviceSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/device/DeviceSettings.java index 4bd8a88af..9f21b5b13 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/device/DeviceSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/device/DeviceSettings.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.device; +package dev.isxander.controlify.impl.config.settings.device; -import dev.isxander.controlify.config.dto.device.DeviceConfig; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.controller.input.mapping.ControllerMapping; +import dev.isxander.controlify.impl.config.dto.device.DeviceConfig; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.input.mapping.ControllerMapping; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; @@ -19,12 +19,13 @@ public class DeviceSettings { public long lastSeen; public Identifier controllerType; public final GyroCalibrationSettings gyroCalibration; + /// Controller must be re-initialized for a change in mapping to take effect. public @Nullable ControllerMapping mapping; private DeviceSettings(String uid) { this.name = uid; this.lastSeen = 0L; - this.controllerType = ControllerType.DEFAULT.namespace(); + this.controllerType = ControllerType.DEFAULT.id(); this.gyroCalibration = GyroCalibrationSettings.defaults(); this.mapping = null; } diff --git a/src/main/java/dev/isxander/controlify/config/settings/device/GyroCalibrationSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/device/GyroCalibrationSettings.java similarity index 64% rename from src/main/java/dev/isxander/controlify/config/settings/device/GyroCalibrationSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/device/GyroCalibrationSettings.java index 51f733d64..047c476e5 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/device/GyroCalibrationSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/device/GyroCalibrationSettings.java @@ -4,21 +4,21 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.device; +package dev.isxander.controlify.impl.config.settings.device; -import dev.isxander.controlify.config.dto.device.GyroCalibrationConfig; -import dev.isxander.controlify.controller.gyro.GyroState; -import dev.isxander.controlify.controller.gyro.GyroStateC; +import dev.isxander.controlify.impl.config.dto.device.GyroCalibrationConfig; +import org.joml.Vector3f; +import org.joml.Vector3fc; public class GyroCalibrationSettings { - public GyroStateC offset; + public Vector3fc offset; private GyroCalibrationSettings() { - this.offset = new GyroState(); + this.offset = new Vector3f(); } public GyroCalibrationSettings( - GyroStateC offset + Vector3fc offset ) { this.offset = offset; } diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/BluetoothDeviceSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/BluetoothDeviceSettings.java similarity index 79% rename from src/main/java/dev/isxander/controlify/config/settings/profile/BluetoothDeviceSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/profile/BluetoothDeviceSettings.java index b1ed3f634..4e3fb98db 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/BluetoothDeviceSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/BluetoothDeviceSettings.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.profile; +package dev.isxander.controlify.impl.config.settings.profile; -import dev.isxander.controlify.config.dto.profile.BluetoothDeviceConfig; +import dev.isxander.controlify.impl.config.dto.profile.BluetoothDeviceConfig; public class BluetoothDeviceSettings { public boolean dontShowWarning; diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/DualsenseSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/DualsenseSettings.java similarity index 78% rename from src/main/java/dev/isxander/controlify/config/settings/profile/DualsenseSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/profile/DualsenseSettings.java index a69a951ac..ab1b8e29b 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/DualsenseSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/DualsenseSettings.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.profile; +package dev.isxander.controlify.impl.config.settings.profile; -import dev.isxander.controlify.config.dto.profile.DualsenseConfig; +import dev.isxander.controlify.impl.config.dto.profile.DualsenseConfig; public class DualsenseSettings { public boolean triggerEffects; diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/GenericControllerSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/GenericControllerSettings.java similarity index 96% rename from src/main/java/dev/isxander/controlify/config/settings/profile/GenericControllerSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/profile/GenericControllerSettings.java index 278455d1a..ce5531b90 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/GenericControllerSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/GenericControllerSettings.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.profile; +package dev.isxander.controlify.impl.config.settings.profile; import dev.isxander.controlify.api.contextual.GuideVerbosity; -import dev.isxander.controlify.config.dto.profile.GenericControllerConfig; +import dev.isxander.controlify.impl.config.dto.profile.GenericControllerConfig; public class GenericControllerSettings { public boolean autoJump; diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/GyroSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/GyroSettings.java similarity index 68% rename from src/main/java/dev/isxander/controlify/config/settings/profile/GyroSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/profile/GyroSettings.java index 806fd23e0..85d174d82 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/GyroSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/GyroSettings.java @@ -4,60 +4,60 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.profile; +package dev.isxander.controlify.impl.config.settings.profile; -import dev.isxander.controlify.config.dto.profile.GyroConfig; -import dev.isxander.controlify.controller.gyro.GyroButtonMode; -import dev.isxander.controlify.controller.gyro.GyroYawMode; +import dev.isxander.controlify.impl.config.dto.profile.GyroConfig; +import dev.isxander.controlify.impl.controller.gyro.GyroButtonMode; +import dev.isxander.controlify.impl.controller.gyro.GyroYawMode; public class GyroSettings { public float lookSensitivity; - public boolean relativeMode; public boolean invertPitch; public boolean invertYaw; public GyroButtonMode buttonMode; public GyroYawMode yawMode; public boolean flickStick; + public float lowPassCutoffHz; public GyroSettings( float lookSensitivity, - boolean relativeMode, boolean invertPitch, boolean invertYaw, GyroButtonMode buttonMode, GyroYawMode yawMode, - boolean flickStick + boolean flickStick, + float lowPassCutoffHz ) { this.lookSensitivity = lookSensitivity; - this.relativeMode = relativeMode; this.invertPitch = invertPitch; this.invertYaw = invertYaw; this.buttonMode = buttonMode; this.yawMode = yawMode; this.flickStick = flickStick; + this.lowPassCutoffHz = lowPassCutoffHz; } public static GyroSettings fromDTO(GyroConfig dto) { return new GyroSettings( dto.lookSensitivity(), - dto.relativeMode(), dto.invertPitch(), dto.invertYaw(), dto.buttonMode(), dto.yawMode(), - dto.flickStick() + dto.flickStick(), + dto.lowPassCutoffHz() ); } public GyroConfig toDTO() { return new GyroConfig( lookSensitivity, - relativeMode, invertPitch, invertYaw, buttonMode, yawMode, - flickStick + flickStick, + lowPassCutoffHz ); } } diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/HDHapticSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/HDHapticSettings.java similarity index 76% rename from src/main/java/dev/isxander/controlify/config/settings/profile/HDHapticSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/profile/HDHapticSettings.java index 8083e9f6d..1d4efd1dc 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/HDHapticSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/HDHapticSettings.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.profile; +package dev.isxander.controlify.impl.config.settings.profile; -import dev.isxander.controlify.config.dto.profile.HDHapticConfig; +import dev.isxander.controlify.impl.config.dto.profile.HDHapticConfig; public class HDHapticSettings { public boolean enabled; diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/InputSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/InputSettings.java similarity index 76% rename from src/main/java/dev/isxander/controlify/config/settings/profile/InputSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/profile/InputSettings.java index cf91cf920..6d0f57abd 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/InputSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/InputSettings.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.profile; +package dev.isxander.controlify.impl.config.settings.profile; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.config.dto.profile.InputConfig; -import dev.isxander.controlify.ingame.InputCurves; +import dev.isxander.controlify.impl.config.dto.profile.InputConfig; +import dev.isxander.controlify.impl.ingame.InputCurves; +import dev.isxander.controlify.api.action.ActionBinding; import net.minecraft.resources.Identifier; import java.util.HashMap; @@ -21,6 +21,7 @@ public class InputSettings { public final BindingsSettings bindings; public final SensitivitySettings sensitivity; public final RadialMenuSettings radialMenu; + public final GestureSettings gestures; public float buttonActivationThreshold; public boolean keepDefaultBindings; @@ -28,12 +29,14 @@ public InputSettings( BindingsSettings bindings, SensitivitySettings sensitivity, RadialMenuSettings radialMenu, + GestureSettings gestures, float buttonActivationThreshold, boolean keepDefaultBindings ) { this.bindings = bindings; this.sensitivity = sensitivity; this.radialMenu = radialMenu; + this.gestures = gestures; this.buttonActivationThreshold = buttonActivationThreshold; this.keepDefaultBindings = keepDefaultBindings; } @@ -43,6 +46,7 @@ public static InputSettings fromDTO(InputConfig dto) { BindingsSettings.fromDTO(dto.bindings()), SensitivitySettings.fromDTO(dto.sensitivity()), RadialMenuSettings.fromDTO(dto.radialMenu()), + GestureSettings.fromDTO(dto.gestures()), dto.buttonActivationThreshold(), dto.keepDefaultBindings() ); @@ -53,18 +57,44 @@ public InputConfig toDTO() { bindings.toDTO(), sensitivity.toDTO(), radialMenu.toDTO(), + gestures.toDTO(), buttonActivationThreshold, keepDefaultBindings ); } + public static class GestureSettings { + public int tapMaxMs; + public int doubleTapGapMs; + public int holdMs; + + public GestureSettings(int tapMaxMs, int doubleTapGapMs, int holdMs) { + this.tapMaxMs = tapMaxMs; + this.doubleTapGapMs = doubleTapGapMs; + this.holdMs = holdMs; + } + + public static GestureSettings fromDTO(InputConfig.GestureConfig dto) { + return new GestureSettings(dto.tapMaxMs(), dto.doubleTapGapMs(), dto.holdMs()); + } + + public InputConfig.GestureConfig toDTO() { + return new InputConfig.GestureConfig(tapMaxMs, doubleTapGapMs, holdMs); + } + } + public static class BindingsSettings { - public Map bindings; + /// Missing entries inherit the controller default; an empty list explicitly unbinds an action. + public Map> bindings; - public BindingsSettings(Map bindings) { + public BindingsSettings(Map> bindings) { this.bindings = new HashMap<>(bindings); } + public List resolve(Identifier actionId, List defaultBindings) { + return this.bindings.getOrDefault(actionId, defaultBindings); + } + public static BindingsSettings fromDTO(InputConfig.BindingsConfig dto) { return new BindingsSettings(dto.bindings()); } diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/ProfileSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/ProfileSettings.java similarity index 92% rename from src/main/java/dev/isxander/controlify/config/settings/profile/ProfileSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/profile/ProfileSettings.java index f4b624f37..6cdc70b68 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/ProfileSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/ProfileSettings.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.profile; +package dev.isxander.controlify.impl.config.settings.profile; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.config.dto.profile.ProfileConfig; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.config.dto.profile.ProfileConfig; import org.jetbrains.annotations.Nullable; import java.util.Optional; diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/RumbleSettings.java b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/RumbleSettings.java similarity index 83% rename from src/main/java/dev/isxander/controlify/config/settings/profile/RumbleSettings.java rename to src/main/java/dev/isxander/controlify/impl/config/settings/profile/RumbleSettings.java index ec8403ba1..b2581d0b5 100644 --- a/src/main/java/dev/isxander/controlify/config/settings/profile/RumbleSettings.java +++ b/src/main/java/dev/isxander/controlify/impl/config/settings/profile/RumbleSettings.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.config.settings.profile; +package dev.isxander.controlify.impl.config.settings.profile; -import dev.isxander.controlify.config.dto.profile.RumbleConfig; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; +import dev.isxander.controlify.impl.config.dto.profile.RumbleConfig; +import dev.isxander.controlify.impl.controller.rumble.RumbleSource; +import dev.isxander.controlify.api.controller.rumble.RumbleState; import net.minecraft.resources.Identifier; import java.util.HashMap; diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualDebug.java b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualDebug.java similarity index 97% rename from src/main/java/dev/isxander/controlify/contextual/ContextualDebug.java rename to src/main/java/dev/isxander/controlify/impl/contextual/ContextualDebug.java index 8f80d80c0..39ee72840 100644 --- a/src/main/java/dev/isxander/controlify/contextual/ContextualDebug.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualDebug.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; -import dev.isxander.controlify.debug.DebugProperties; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.debug.DebugProperties; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; import net.minecraft.world.entity.Entity; import net.minecraft.world.item.ItemInstance; diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualDomainImpl.java b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualDomainImpl.java similarity index 84% rename from src/main/java/dev/isxander/controlify/contextual/ContextualDomainImpl.java rename to src/main/java/dev/isxander/controlify/impl/contextual/ContextualDomainImpl.java index 045575ca7..4935e7558 100644 --- a/src/main/java/dev/isxander/controlify/contextual/ContextualDomainImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualDomainImpl.java @@ -4,17 +4,18 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.mojang.serialization.DataResult; import com.mojang.serialization.JsonOps; import dev.isxander.controlify.api.contextual.*; -import dev.isxander.controlify.controller.dualsense.TriggerEffectInstanceImpl; -import dev.isxander.controlify.gui.guide.GuideInstanceImpl; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.controller.dualsense.TriggerEffectInstanceImpl; +import dev.isxander.controlify.impl.gui.guide.GuideInstanceImpl; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.multiplayer.CacheSlot; @@ -29,6 +30,7 @@ import net.minecraft.util.PlaceholderLookupProvider; import net.minecraft.util.RegistryContextSwapper; import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; import java.io.BufferedReader; import java.util.*; @@ -36,9 +38,10 @@ import java.util.concurrent.Executor; public final class ContextualDomainImpl implements ContextualDomain, SimpleControlifyReloadListener { - public static final String DIRECTORY = "contextual/facts"; + public static final String DIRECTORY = "controller/contextual/facts"; private static final FileToIdConverter converter = FileToIdConverter.json(DIRECTORY); + private final ControlifyLogger logger; private final Identifier id; private final CacheSlot resolvedFactGraphs = new CacheSlot<>(this::resolveFactGraph); private ContextualStateContributor contributor = ContextualStateContributor.noop(); @@ -47,30 +50,31 @@ public final class ContextualDomainImpl implements Contextual public ContextualDomainImpl(Identifier id) { this.id = Objects.requireNonNull(id, "id"); + this.logger = CUtil.LOGGER.createSubLogger("ContextualDomain " + this.id); this.registerContributor(ContextualStateContributors.COMMON); } @Override - public Identifier id() { + public @NonNull Identifier id() { return this.id; } @Override - public void registerContributor(ContextualStateContributor contributor) { + public void registerContributor(@NonNull ContextualStateContributor contributor) { Objects.requireNonNull(contributor, "contributor"); this.contributor = this.contributor.andThen(contributor); } @Override - public GuideInstance createGuideInstance(Font font) { + public @NonNull GuideInstance createGuideInstance(@NonNull Font font) { RuleSetManager ruleSetManager = RuleSetManager.GUIDE_RULES; return new GuideInstanceImpl<>(this, () -> this.requireRuleEngine(ruleSetManager, "guide"), font); } @Override - public TriggerEffectInstance createTriggerEffectInstance() { + public @NonNull TriggerEffectInstance createTriggerEffectInstance() { RuleSetManager ruleSetManager = RuleSetManager.TRIGGER_EFFECT_RULES; return new TriggerEffectInstanceImpl<>(this, () -> this.requireRuleEngine(ruleSetManager, "trigger effect")); @@ -84,7 +88,13 @@ private > RuleEngine requireRuleEngine(RuleSetManager @Override public CompletableFuture load(ResourceManager manager, Executor executor) { return CompletableFuture.supplyAsync(() -> { - List resources = manager.getResourceStack(converter.idToFile(this.id)); + Identifier file = converter.idToFile(this.id); + List resources = manager.getResourceStack(file); + + if (resources.isEmpty()) { + CUtil.LOGGER.error("Found 0 entries in resource stack {} while loading contextual domain {}", file, this.id); + } + var lookup = new PlaceholderLookupProvider(RegistryAccess.EMPTY); var ops = lookup.createSerializationContext(JsonOps.INSTANCE); @@ -138,6 +148,9 @@ public CompletableFuture apply(Preparations data, ResourceManager manager, } private synchronized void apply(Preparations data) { + if (data.facts().isEmpty()) { + this.logger.warn("Contextual domain {} loaded with 0 derived facts. Issue?", this.id); + } this.preparations = data; this.factGraphWithoutLevel = data.factGraphWithoutLevel(); this.resolvedFactGraphs.clear(); @@ -199,7 +212,7 @@ private synchronized FactDependencyGraph currentFactGraph(@Nullable ClientLevel @Override public Identifier getReloadId() { - return this.id.withPrefix("reload/"); + return this.id.withPrefix("contextual/domain/"); } public ContextualState calculateState(C context, @Nullable Collection requiredFacts) { diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualDomains.java b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualDomains.java similarity index 57% rename from src/main/java/dev/isxander/controlify/contextual/ContextualDomains.java rename to src/main/java/dev/isxander/controlify/impl/contextual/ContextualDomains.java index f69f272b0..05c185d1b 100644 --- a/src/main/java/dev/isxander/controlify/contextual/ContextualDomains.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualDomains.java @@ -4,25 +4,34 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import dev.isxander.controlify.api.contextual.ContainerContext; import dev.isxander.controlify.api.contextual.InGameContext; import dev.isxander.controlify.api.contextual.Context; import dev.isxander.controlify.api.contextual.ContextualDomain; import dev.isxander.controlify.api.contextual.ContextualDomainRegistry; -import dev.isxander.controlify.platform.client.PlatformClientUtil; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.event.EventBus; import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; import java.util.ArrayList; import java.util.List; public final class ContextualDomains implements ContextualDomainRegistry { + /// Loading this holder does not construct domains or register resource listeners. + public static final class Events { + public static final EventBus REGISTER_DOMAINS = new EventBus<>(); + + private Events() {} + } public static final ContextualDomains INSTANCE = new ContextualDomains(); private final List> domains = new ArrayList<>(); + private boolean locked; private final ContextualDomain inGame = register( CUtil.rl("in_game"), ContextualStateContributors.IN_GAME @@ -33,17 +42,23 @@ public final class ContextualDomains implements ContextualDomainRegistry { ); @Override - public ContextualDomain inGame() { + public @NonNull ContextualDomain inGame() { return this.inGame; } @Override - public ContextualDomain container() { + public @NonNull ContextualDomain container() { return this.container; } @Override - public ContextualDomain register(Identifier domainId) { + public @NonNull ContextualDomain register(@NonNull Identifier domainId) { + if (this.locked) { + throw new IllegalStateException("Contextual domains must be registered during REGISTER_DOMAINS"); + } + if (this.domains.stream().anyMatch(domain -> domain.id().equals(domainId))) { + throw new IllegalArgumentException("Contextual domain is already registered: " + domainId); + } var domain = new ContextualDomainImpl(domainId); this.domains.add(domain); PlatformClientUtil.registerAssetReloadListener(domain); @@ -56,6 +71,10 @@ public void invalidateResolvedFactGraphs() { public static void touch() {} + public void lock() { + this.locked = true; + } + private ContextualDomains() { } } diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualPredicate.java b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualPredicate.java similarity index 95% rename from src/main/java/dev/isxander/controlify/contextual/ContextualPredicate.java rename to src/main/java/dev/isxander/controlify/impl/contextual/ContextualPredicate.java index b1d674da5..1969642ee 100644 --- a/src/main/java/dev/isxander/controlify/contextual/ContextualPredicate.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualPredicate.java @@ -4,19 +4,19 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.utils.codec.CExtraCodecs; -import dev.isxander.controlify.utils.predicates.ClientEntityPredicate; -import dev.isxander.controlify.utils.predicates.ClientPredicateUtils; +import dev.isxander.controlify.impl.utils.codec.CExtraCodecs; +import dev.isxander.controlify.impl.utils.predicates.ClientEntityPredicate; +import dev.isxander.controlify.impl.utils.predicates.ClientPredicateUtils; //? if >=26.2 { import net.minecraft.advancements.predicates.*; //?} else { -/*import net.minecraft.advancements.criterion.*; -*///?} +//import net.minecraft.advancements.criterion.*; +//?} import net.minecraft.client.Minecraft; import net.minecraft.resources.Identifier; diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualState.java b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualState.java similarity index 92% rename from src/main/java/dev/isxander/controlify/contextual/ContextualState.java rename to src/main/java/dev/isxander/controlify/impl/contextual/ContextualState.java index 5e36298c8..7625e9619 100644 --- a/src/main/java/dev/isxander/controlify/contextual/ContextualState.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualState.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import net.minecraft.resources.Identifier; import net.minecraft.world.entity.Entity; diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualStateAccumulator.java b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualStateAccumulator.java similarity index 83% rename from src/main/java/dev/isxander/controlify/contextual/ContextualStateAccumulator.java rename to src/main/java/dev/isxander/controlify/impl/contextual/ContextualStateAccumulator.java index a43ea2778..09cafe5fe 100644 --- a/src/main/java/dev/isxander/controlify/contextual/ContextualStateAccumulator.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualStateAccumulator.java @@ -4,15 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import dev.isxander.controlify.api.contextual.ContextualStateSink; import net.minecraft.resources.Identifier; import net.minecraft.world.entity.Entity; import net.minecraft.world.item.ItemInstance; -import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.state.pattern.BlockInWorld; import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; import java.util.Collections; import java.util.LinkedHashMap; @@ -36,12 +36,12 @@ public ContextualStateAccumulator(ContextualState state) { } @Override - public void contributeFact(Identifier id, boolean value) { + public void contributeFact(@NonNull Identifier id, boolean value) { this.facts.put(Objects.requireNonNull(id, "id"), value); } @Override - public void contributeItem(Identifier slot, @Nullable ItemInstance item) { + public void contributeItem(@NonNull Identifier slot, @Nullable ItemInstance item) { if (item == null) { return; } @@ -49,7 +49,7 @@ public void contributeItem(Identifier slot, @Nullable ItemInstance item) { } @Override - public void contributeBlock(Identifier slot, @Nullable BlockInWorld block) { + public void contributeBlock(@NonNull Identifier slot, @Nullable BlockInWorld block) { if (block == null) { return; } @@ -57,7 +57,7 @@ public void contributeBlock(Identifier slot, @Nullable BlockInWorld block) { } @Override - public void contributeEntity(Identifier slot, @Nullable Entity entity) { + public void contributeEntity(@NonNull Identifier slot, @Nullable Entity entity) { if (entity == null) { return; } diff --git a/src/main/java/dev/isxander/controlify/contextual/ContextualStateContributors.java b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualStateContributors.java similarity index 94% rename from src/main/java/dev/isxander/controlify/contextual/ContextualStateContributors.java rename to src/main/java/dev/isxander/controlify/impl/contextual/ContextualStateContributors.java index f2a0ad0a4..a81430411 100644 --- a/src/main/java/dev/isxander/controlify/contextual/ContextualStateContributors.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/ContextualStateContributors.java @@ -4,11 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import dev.isxander.controlify.api.contextual.*; +import dev.isxander.controlify.impl.controller.ControllerImpl; import dev.isxander.controlify.mixins.feature.guide.ingame.PlayerAccessor; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.BlockItem; @@ -103,11 +104,11 @@ public final class ContextualStateContributors { sink.contributeFact( CUtil.rl("is_toggle_sneak"), - context.controller().settings().generic.toggleSneak + ((ControllerImpl) context.controller()).settings().generic.toggleSneak ); sink.contributeFact( CUtil.rl("is_toggle_sprint"), - context.controller().settings().generic.toggleSprint + ((ControllerImpl) context.controller()).settings().generic.toggleSprint ); sink.contributeFact( CUtil.rl("input_moving"), diff --git a/src/main/java/dev/isxander/controlify/contextual/CyclicFactDependencyException.java b/src/main/java/dev/isxander/controlify/impl/contextual/CyclicFactDependencyException.java similarity index 93% rename from src/main/java/dev/isxander/controlify/contextual/CyclicFactDependencyException.java rename to src/main/java/dev/isxander/controlify/impl/contextual/CyclicFactDependencyException.java index 7fa8df3f1..458b6adf2 100644 --- a/src/main/java/dev/isxander/controlify/contextual/CyclicFactDependencyException.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/CyclicFactDependencyException.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import net.minecraft.resources.Identifier; diff --git a/src/main/java/dev/isxander/controlify/contextual/FactDefinition.java b/src/main/java/dev/isxander/controlify/impl/contextual/FactDefinition.java similarity index 95% rename from src/main/java/dev/isxander/controlify/contextual/FactDefinition.java rename to src/main/java/dev/isxander/controlify/impl/contextual/FactDefinition.java index 32b4782ca..49cf251a6 100644 --- a/src/main/java/dev/isxander/controlify/contextual/FactDefinition.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/FactDefinition.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import com.mojang.serialization.Codec; import net.minecraft.resources.Identifier; diff --git a/src/main/java/dev/isxander/controlify/contextual/FactDependencyGraph.java b/src/main/java/dev/isxander/controlify/impl/contextual/FactDependencyGraph.java similarity index 99% rename from src/main/java/dev/isxander/controlify/contextual/FactDependencyGraph.java rename to src/main/java/dev/isxander/controlify/impl/contextual/FactDependencyGraph.java index bdfdae8c7..67ed4ffcd 100644 --- a/src/main/java/dev/isxander/controlify/contextual/FactDependencyGraph.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/FactDependencyGraph.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; diff --git a/src/main/java/dev/isxander/controlify/api/contextual/GuideLocation.java b/src/main/java/dev/isxander/controlify/impl/contextual/GuideLocation.java similarity index 93% rename from src/main/java/dev/isxander/controlify/api/contextual/GuideLocation.java rename to src/main/java/dev/isxander/controlify/impl/contextual/GuideLocation.java index 2a5d38d15..f6d8ba2d0 100644 --- a/src/main/java/dev/isxander/controlify/api/contextual/GuideLocation.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/GuideLocation.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.api.contextual; +package dev.isxander.controlify.impl.contextual; import com.mojang.serialization.Codec; import net.minecraft.util.StringRepresentable; diff --git a/src/main/java/dev/isxander/controlify/contextual/GuideRule.java b/src/main/java/dev/isxander/controlify/impl/contextual/GuideRule.java similarity index 72% rename from src/main/java/dev/isxander/controlify/contextual/GuideRule.java rename to src/main/java/dev/isxander/controlify/impl/contextual/GuideRule.java index 8da435544..6e3f4be7e 100644 --- a/src/main/java/dev/isxander/controlify/contextual/GuideRule.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/GuideRule.java @@ -4,24 +4,23 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.api.contextual.GuideLocation; +import dev.isxander.controlify.api.action.ActionAccessor; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.ComponentSerialization; import net.minecraft.resources.Identifier; public record GuideRule( - InputBindingSupplier binding, + ActionAccessor action, GuideLocation location, ContextualPredicate predicate, Component text ) implements Rule { public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - InputBindingSupplier.CODEC.fieldOf("for").forGetter(GuideRule::binding), + ActionAccessor.CODEC.fieldOf("for").forGetter(GuideRule::action), GuideLocation.CODEC.fieldOf("where").forGetter(GuideRule::location), ContextualPredicate.CODEC.fieldOf("if").forGetter(GuideRule::predicate), ComponentSerialization.CODEC.fieldOf("then").forGetter(GuideRule::text) @@ -29,11 +28,11 @@ public record GuideRule( @Override public Key key() { - return new Key(this.binding().bindId(), this.location()); + return new Key(this.action().actionId(), this.location()); } public record Key( - Identifier binding, + Identifier action, GuideLocation location ) {} } diff --git a/src/main/java/dev/isxander/controlify/contextual/Rule.java b/src/main/java/dev/isxander/controlify/impl/contextual/Rule.java similarity index 91% rename from src/main/java/dev/isxander/controlify/contextual/Rule.java rename to src/main/java/dev/isxander/controlify/impl/contextual/Rule.java index 9c092d6cc..ecd785118 100644 --- a/src/main/java/dev/isxander/controlify/contextual/Rule.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/Rule.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; public interface Rule { /// Used to identify when multiple rules are overriding each other. diff --git a/src/main/java/dev/isxander/controlify/contextual/RuleEngine.java b/src/main/java/dev/isxander/controlify/impl/contextual/RuleEngine.java similarity index 98% rename from src/main/java/dev/isxander/controlify/contextual/RuleEngine.java rename to src/main/java/dev/isxander/controlify/impl/contextual/RuleEngine.java index 3c7fcfc26..9609a53d5 100644 --- a/src/main/java/dev/isxander/controlify/contextual/RuleEngine.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/RuleEngine.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; diff --git a/src/main/java/dev/isxander/controlify/contextual/RuleSet.java b/src/main/java/dev/isxander/controlify/impl/contextual/RuleSet.java similarity index 93% rename from src/main/java/dev/isxander/controlify/contextual/RuleSet.java rename to src/main/java/dev/isxander/controlify/impl/contextual/RuleSet.java index 0f957ba16..8ac7b2825 100644 --- a/src/main/java/dev/isxander/controlify/contextual/RuleSet.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/RuleSet.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; diff --git a/src/main/java/dev/isxander/controlify/contextual/RuleSetManager.java b/src/main/java/dev/isxander/controlify/impl/contextual/RuleSetManager.java similarity index 95% rename from src/main/java/dev/isxander/controlify/contextual/RuleSetManager.java rename to src/main/java/dev/isxander/controlify/impl/contextual/RuleSetManager.java index 238adf9db..479b2aea3 100644 --- a/src/main/java/dev/isxander/controlify/contextual/RuleSetManager.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/RuleSetManager.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import com.google.gson.JsonElement; import com.google.gson.JsonParser; @@ -12,8 +12,8 @@ import com.mojang.serialization.DataResult; import com.mojang.serialization.DynamicOps; import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.multiplayer.CacheSlot; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.client.multiplayer.ClientRegistryLayer; @@ -37,10 +37,11 @@ public class RuleSetManager> implements SimpleControlifyRel public static final RuleSetManager TRIGGER_EFFECT_RULES = new RuleSetManager<>(TriggerEffectRule.CODEC, "trigger_effect"); - private static final String DIRECTORY = "contextual/"; + private static final String DIRECTORY = "controller/contextual/"; private final Codec> ruleSetCodec; private final Codec ruleCodec; + private final Identifier reloadId; private final String directory; private final FileToIdConverter converter; private final CacheSlot>> resolvedRuleEngines = @@ -52,6 +53,7 @@ public class RuleSetManager> implements SimpleControlifyRel public RuleSetManager(Codec ruleCodec, String directory) { this.ruleSetCodec = RuleSet.createCodec(ruleCodec); this.ruleCodec = ruleCodec; + this.reloadId = CUtil.rl(directory).withPrefix("contextual/ruleset/"); this.directory = DIRECTORY + directory; this.converter = FileToIdConverter.json(this.directory); this.preparations = new Preparations<>(Map.of(), null); diff --git a/src/main/java/dev/isxander/controlify/contextual/TriggerEffectRule.java b/src/main/java/dev/isxander/controlify/impl/contextual/TriggerEffectRule.java similarity index 58% rename from src/main/java/dev/isxander/controlify/contextual/TriggerEffectRule.java rename to src/main/java/dev/isxander/controlify/impl/contextual/TriggerEffectRule.java index 370401396..fe82ffca1 100644 --- a/src/main/java/dev/isxander/controlify/contextual/TriggerEffectRule.java +++ b/src/main/java/dev/isxander/controlify/impl/contextual/TriggerEffectRule.java @@ -4,30 +4,30 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.contextual; +package dev.isxander.controlify.impl.contextual; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.controller.dualsense.TriggerEffectCodecs; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.impl.controller.dualsense.TriggerEffectCodecs; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseTriggerEffect; import net.minecraft.resources.Identifier; -/// @param binding the binding supplier associated with this action. -/// if the binding is not bound to either trigger, it is ignored. +/// @param action the action supplier associated with this action. +/// if the action is not bound to either trigger, it is ignored. public record TriggerEffectRule( - InputBindingSupplier binding, + ActionAccessor action, ContextualPredicate predicate, DualsenseTriggerEffect effect ) implements Rule { public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - InputBindingSupplier.CODEC.fieldOf("for").forGetter(TriggerEffectRule::binding), + ActionAccessor.CODEC.fieldOf("for").forGetter(TriggerEffectRule::action), ContextualPredicate.CODEC.fieldOf("if").forGetter(TriggerEffectRule::predicate), TriggerEffectCodecs.CODEC.fieldOf("then").forGetter(TriggerEffectRule::effect) ).apply(instance, TriggerEffectRule::new)); @Override public Identifier key() { - return this.binding.bindId(); + return this.action.actionId(); } } diff --git a/src/main/java/dev/isxander/controlify/impl/controller/ControllerComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/ControllerComponent.java new file mode 100644 index 000000000..1a099e570 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/ControllerComponent.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller; + +import net.minecraft.resources.Identifier; + +public abstract class ControllerComponent { + private final Identifier id; + private ControllerImpl controller; + + public ControllerComponent(Identifier id) { + this.id = id; + } + + public final Identifier id() { + return this.id; + } + + public void attach(ControllerImpl controller) { + this.controller = controller; + } + + public void close() { + } + + protected final ControllerImpl controller() { + return this.controller; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/ControllerImpl.java b/src/main/java/dev/isxander/controlify/impl/controller/ControllerImpl.java new file mode 100644 index 000000000..a99fb57fa --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/ControllerImpl.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.controller.battery.BatteryLevelComponent; +import dev.isxander.controlify.impl.controller.dualsense.DualsenseComponentImpl; +import dev.isxander.controlify.impl.controller.gyro.GyroComponentImpl; +import dev.isxander.controlify.impl.controller.haptic.HDHapticComponent; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.impl.controller.led.LEDComponent; +import dev.isxander.controlify.impl.controller.misc.BluetoothDeviceComponent; +import dev.isxander.controlify.impl.controller.misc.DriverNameComponent; +import dev.isxander.controlify.impl.controller.misc.UIDComponent; +import dev.isxander.controlify.impl.controller.rumble.RumbleComponentImpl; +import dev.isxander.controlify.impl.controller.rumble.TriggerRumbleComponent; +import dev.isxander.controlify.impl.controller.steamdeck.SteamDeckComponent; +import dev.isxander.controlify.impl.controller.touchpad.TouchpadComponent; +import dev.isxander.controlify.impl.driver.Driver; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +public final class ControllerImpl implements Controller { + private final ControllerInfo info; + private final Driver driver; + private final ControlifyLogger logger; + + private final Map components; + + private ProfileSettings settings; + private ProfileSettings defaultSettings; + + private boolean active; + + public ControllerImpl( + ControllerInfo info, + Driver driver, + ControlifyLogger logger, + ProfileSettings settings, + ProfileSettings defaultSettings + ) { + this.info = info; + this.driver = driver; + this.logger = logger; + this.components = new HashMap<>(); + this.settings = settings; + this.defaultSettings = defaultSettings; + this.active = false; + + this.driver.addComponents(this); + this.getAllComponents().values().forEach(c -> c.attach(this)); + + logger.debugLog( + "Components: {}", + this.getAllComponents() + .keySet() + .stream() + .map(Identifier::toString) + .collect(Collectors.joining(", ")) + ); + } + + @Override + public @NonNull String uid() { + return this.getComponent(UIDComponent.ID).orElseThrow().value(); + } + + @Override + public @NonNull String driverReportedName() { + return this.getComponent(DriverNameComponent.ID).orElseThrow().value(); + } + + @Override + public @NonNull String name() { + return this.info.type().friendlyName() + .orElse(this.driverReportedName()); + } + + @Override + public @NonNull Identifier typeId() { + return this.info.type().id(); + } + + public @NonNull ControllerInfo info() { + return this.info; + } + + public @NonNull Driver driver() { + return this.driver; + } + + public @NonNull ProfileSettings settings() { + return this.settings; + } + + public void setSettings(@NonNull ProfileSettings settings) { + this.settings = settings; + this.input().reloadActions(); + } + + public void reloadDefaultSettings() { + this.defaultSettings = ProfileSettings.createDefault(); + } + + public @NonNull ProfileSettings defaultSettings() { + return this.defaultSettings; + } + + @Override + public @NonNull InputComponentImpl input() { + // assert that all controllers must have InputComponentImpl + return this.getComponent(InputComponentImpl.ID).orElseThrow(); + } + + @Override + public @NonNull Optional rumble() { + return this.getComponent(RumbleComponentImpl.ID); + } + + public @NonNull Optional triggerRumble() { + return this.getComponent(TriggerRumbleComponent.ID); + } + + @Override + public @NonNull Optional gyro() { + return this.getComponent(GyroComponentImpl.ID); + } + + public @NonNull Optional led() { + return this.getComponent(LEDComponent.ID); + } + + public @NonNull Optional touchpad() { + return this.getComponent(TouchpadComponent.ID); + } + + public @NonNull Optional batteryLevel() { + return this.getComponent(BatteryLevelComponent.ID); + } + + @Override + public @NonNull Optional dualsense() { + return this.getComponent(DualsenseComponentImpl.ID); + } + + public @NonNull Optional hdHaptics() { + return this.getComponent(HDHapticComponent.ID); + } + + public @NonNull Optional bluetoothDevice() { + return this.getComponent(BluetoothDeviceComponent.ID); + } + + public @NonNull Optional steamDeck() { + return this.getComponent(SteamDeckComponent.ID); + } + + public void update() { + this.driver.update(this); + } + + public boolean isActive() { + return this.active; + } + + public void setActive(boolean active) { + if (this.active == active) { + return; + } + this.active = active; + this.driver.setActive(active); + if (!active) { + this.input().resetActions(); + } + } + + public void close() { + this.setActive(false); + this.getAllComponents().values().forEach(ControllerComponent::close); + this.driver.close(); + } + + public ControlifyLogger logger() { + return this.logger; + } + + @SuppressWarnings("unchecked") + private Optional getComponent(Identifier id) { + return Optional.ofNullable((T) this.components.get(id)); + } + + public boolean addComponent(ControllerComponent component) { + return this.components.put(component.id(), component) != null; + } + + public Map getAllComponents() { + return Collections.unmodifiableMap(this.components); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/ControllerInfo.java b/src/main/java/dev/isxander/controlify/impl/controller/ControllerInfo.java new file mode 100644 index 000000000..09c65f859 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/ControllerInfo.java @@ -0,0 +1,14 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller; + +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.controllermanager.UniqueControllerID; +import dev.isxander.controlify.impl.hid.HIDID; + +public record ControllerInfo(UniqueControllerID ucid, ControllerType type, HIDID hid) { +} diff --git a/src/main/java/dev/isxander/controlify/controller/SingleValueComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/SingleValueControllerComponent.java similarity index 50% rename from src/main/java/dev/isxander/controlify/controller/SingleValueComponent.java rename to src/main/java/dev/isxander/controlify/impl/controller/SingleValueControllerComponent.java index 042da6abe..6301606f2 100644 --- a/src/main/java/dev/isxander/controlify/controller/SingleValueComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/SingleValueControllerComponent.java @@ -4,25 +4,19 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller; +package dev.isxander.controlify.impl.controller; import net.minecraft.resources.Identifier; -public class SingleValueComponent implements ECSComponent { +public class SingleValueControllerComponent extends ControllerComponent { private final T value; - private final Identifier id; - public SingleValueComponent(T value, Identifier id) { + public SingleValueControllerComponent(Identifier id, T value) { + super(id); this.value = value; - this.id = id; } public T value() { return this.value; } - - @Override - public Identifier id() { - return this.id; - } } diff --git a/src/main/java/dev/isxander/controlify/controller/battery/BatteryLevelComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/battery/BatteryLevelComponent.java similarity index 62% rename from src/main/java/dev/isxander/controlify/controller/battery/BatteryLevelComponent.java rename to src/main/java/dev/isxander/controlify/impl/controller/battery/BatteryLevelComponent.java index cc8e748ca..3271ef9a2 100644 --- a/src/main/java/dev/isxander/controlify/controller/battery/BatteryLevelComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/battery/BatteryLevelComponent.java @@ -4,17 +4,21 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.battery; +package dev.isxander.controlify.impl.controller.battery; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; -public class BatteryLevelComponent extends ECSComponentImpl { +public class BatteryLevelComponent extends ControllerComponent { public static final Identifier ID = CUtil.rl("battery_level"); private PowerState batteryLevel = new PowerState.Unknown(); + public BatteryLevelComponent() { + super(ID); + } + public PowerState getBatteryLevel() { return this.batteryLevel; } @@ -22,9 +26,4 @@ public PowerState getBatteryLevel() { public void setBatteryLevel(PowerState batteryLevel) { this.batteryLevel = batteryLevel; } - - @Override - public Identifier id() { - return ID; - } } diff --git a/src/main/java/dev/isxander/controlify/controller/battery/PowerState.java b/src/main/java/dev/isxander/controlify/impl/controller/battery/PowerState.java similarity index 89% rename from src/main/java/dev/isxander/controlify/controller/battery/PowerState.java rename to src/main/java/dev/isxander/controlify/impl/controller/battery/PowerState.java index 82ce9a6ed..26e07e07c 100644 --- a/src/main/java/dev/isxander/controlify/controller/battery/PowerState.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/battery/PowerState.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.battery; +package dev.isxander.controlify.impl.controller.battery; public sealed interface PowerState { default int percent() { diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/DualsenseComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/dualsense/DualsenseComponentImpl.java similarity index 54% rename from src/main/java/dev/isxander/controlify/controller/dualsense/DualsenseComponent.java rename to src/main/java/dev/isxander/controlify/impl/controller/dualsense/DualsenseComponentImpl.java index 7f8741f2f..0ec45944c 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/DualsenseComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/dualsense/DualsenseComponentImpl.java @@ -4,26 +4,34 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.dualsense; - -import dev.isxander.controlify.config.settings.profile.DualsenseSettings; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; -import dev.isxander.controlify.utils.CUtil; +package dev.isxander.controlify.impl.controller.dualsense; + +import dev.isxander.controlify.api.controller.dualsense.DualsenseComponent; +import dev.isxander.controlify.api.controller.dualsense.MicLightMode; +import dev.isxander.controlify.impl.config.settings.profile.DualsenseSettings; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import java.util.Objects; -public class DualsenseComponent extends ECSComponentImpl { +public class DualsenseComponentImpl extends ControllerComponent implements DualsenseComponent { public static final Identifier ID = CUtil.rl("dualsense"); - private boolean muteLight; + private MicLightMode micLight = MicLightMode.OFF; - private DualsenseTriggerEffect leftTriggerEffect; - private DualsenseTriggerEffect rightTriggerEffect; + private @Nullable DualsenseTriggerEffect leftTriggerEffect; + private @Nullable DualsenseTriggerEffect rightTriggerEffect; private boolean dirty; + public DualsenseComponentImpl() { + super(ID); + } + public void setLeftTriggerEffect(DualsenseTriggerEffect effect) { if (!Objects.equals(effect, this.leftTriggerEffect)) { this.setDirty(); @@ -46,15 +54,16 @@ public DualsenseTriggerEffect getRightTriggerEffect() { return this.rightTriggerEffect; } - public void setMuteLight(boolean on) { - if (this.muteLight != on) { - this.muteLight = on; + @Override + public void setMicLightMode(@NonNull MicLightMode mode) { + if (this.micLight != mode) { + this.micLight = mode; this.setDirty(); } } - public boolean getMuteLight() { - return this.muteLight; + public MicLightMode getMicLightMode() { + return this.micLight; } private void setDirty() { @@ -74,9 +83,4 @@ public DualsenseSettings settings() { public DualsenseSettings defaultSettings() { return this.controller().defaultSettings().dualsense; } - - @Override - public Identifier id() { - return ID; - } } diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectCodecs.java b/src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectCodecs.java similarity index 96% rename from src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectCodecs.java rename to src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectCodecs.java index 8f1822d89..c8b31cb4f 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectCodecs.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectCodecs.java @@ -4,13 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.dualsense; +package dev.isxander.controlify.impl.controller.dualsense; import com.mojang.serialization.Codec; import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; -import dev.isxander.controlify.utils.codec.CExtraCodecs; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.impl.utils.codec.CExtraCodecs; import net.minecraft.util.ExtraCodecs; import java.util.Map; diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectInstanceImpl.java b/src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectInstanceImpl.java similarity index 56% rename from src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectInstanceImpl.java rename to src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectInstanceImpl.java index fe6bdbbef..9586786d1 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectInstanceImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectInstanceImpl.java @@ -4,19 +4,21 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.dualsense; +package dev.isxander.controlify.impl.controller.dualsense; -import dev.isxander.controlify.api.bind.InputBinding; +import dev.isxander.controlify.api.action.Action; import dev.isxander.controlify.api.contextual.Context; import dev.isxander.controlify.api.contextual.TriggerEffectInstance; -import dev.isxander.controlify.contextual.ContextualDomainImpl; -import dev.isxander.controlify.contextual.ContextualState; -import dev.isxander.controlify.contextual.RuleEngine; -import dev.isxander.controlify.contextual.TriggerEffectRule; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.input.GamepadInputs; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.contextual.ContextualDomainImpl; +import dev.isxander.controlify.impl.contextual.ContextualState; +import dev.isxander.controlify.impl.contextual.RuleEngine; +import dev.isxander.controlify.impl.contextual.TriggerEffectRule; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.impl.input.mapping.GamepadInputs; import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; import java.util.List; import java.util.function.Supplier; @@ -37,9 +39,11 @@ public TriggerEffectInstanceImpl( } @Override - public void update(C context) { + public void update(@NonNull C context) { + var controller = (ControllerImpl) context.controller(); + RuleEngine ruleEngine = this.ruleEngineSupplier.get(); - boolean useTriggerEffects = context.controller().dualSense() + boolean useTriggerEffects = controller.dualsense() .map(ds -> ds.settings().triggerEffects) .orElse(false); if (!useTriggerEffects) { @@ -51,46 +55,46 @@ public void update(C context) { ContextualState state = this.domain.calculateState(context, ruleEngine.factDependencies()); List passedRules = ruleEngine.evaluate( state, - rule -> getTrigger(context.controller(), rule) != Trigger.NEITHER, + rule -> getTrigger(controller, rule) != Trigger.NEITHER, "trigger_effect/" + this.domain.id() ); // there should only be one each this.leftEffect = passedRules.stream() - .filter(rule -> getTrigger(context.controller(), rule) == Trigger.LEFT) + .filter(rule -> getTrigger(controller, rule) == Trigger.LEFT) .map(TriggerEffectRule::effect) .findAny() .orElse(DualsenseTriggerEffect.Off.INSTANCE); this.rightEffect = passedRules.stream() - .filter(rule -> getTrigger(context.controller(), rule) == Trigger.RIGHT) + .filter(rule -> getTrigger(controller, rule) == Trigger.RIGHT) .map(TriggerEffectRule::effect) .findAny() .orElse(DualsenseTriggerEffect.Off.INSTANCE); } @Override - public DualsenseTriggerEffect getLeftTriggerEffect() { + public @NonNull DualsenseTriggerEffect getLeftTriggerEffect() { return this.leftEffect; } @Override - public DualsenseTriggerEffect getRightTriggerEffect() { + public @NonNull DualsenseTriggerEffect getRightTriggerEffect() { return this.rightEffect; } - private Trigger getTrigger(ControllerEntity controller, TriggerEffectRule rule) { - InputBinding binding = rule.binding().onOrNull(controller); + private Trigger getTrigger(ControllerImpl controller, TriggerEffectRule rule) { + Action action = rule.action().onOrNull(controller); - if (binding == null) { + if (action == null) { return Trigger.NEITHER; } - List relevantInputs = binding.boundInput().getRelevantInputs(); - - if (relevantInputs.contains(GamepadInputs.LEFT_TRIGGER_AXIS)) { - return Trigger.LEFT; - } else if (relevantInputs.contains(GamepadInputs.RIGHT_TRIGGER_AXIS)) { - return Trigger.RIGHT; + if (action.primaryInput() instanceof LogicalInput.Single(Identifier physicalInput)) { + if (GamepadInputs.LEFT_TRIGGER_AXIS.equals(physicalInput)) { + return Trigger.LEFT; + } else if (GamepadInputs.RIGHT_TRIGGER_AXIS.equals(physicalInput)) { + return Trigger.RIGHT; + } } return Trigger.NEITHER; diff --git a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java b/src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectManager.java similarity index 67% rename from src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java rename to src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectManager.java index 1052acf8c..8ddaed4bf 100644 --- a/src/main/java/dev/isxander/controlify/controller/dualsense/TriggerEffectManager.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/dualsense/TriggerEffectManager.java @@ -4,18 +4,16 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.dualsense; +package dev.isxander.controlify.impl.controller.dualsense; import dev.isxander.controlify.api.contextual.InGameContext; import dev.isxander.controlify.api.contextual.TriggerEffectInstance; -import dev.isxander.controlify.contextual.ContextualDomains; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.contextual.ContextualDomains; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.Minecraft; -import java.util.*; - public class TriggerEffectManager { private final TriggerEffectInstance instance; @@ -25,13 +23,13 @@ public TriggerEffectManager() { } public void applyTriggerEffects( - ControllerEntity controller, + ControllerImpl controller, boolean inputSuppressed ) { applyTriggerEffects(controller, instance, inputSuppressed); } - public void tick(Minecraft minecraft, ControllerEntity controller) { + public void tick(Minecraft minecraft, ControllerImpl controller) { if (minecraft.player == null || minecraft.level == null) { return; } @@ -42,11 +40,11 @@ public void tick(Minecraft minecraft, ControllerEntity controller) { } public static void applyTriggerEffects( - ControllerEntity controller, + ControllerImpl controller, TriggerEffectInstance instance, boolean inputSuppressed ) { - controller.dualSense().ifPresent(ds -> { + controller.dualsense().ifPresent(ds -> { if (!shouldUseTriggerEffects(controller, inputSuppressed)) { ds.setLeftTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); ds.setRightTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); @@ -58,10 +56,10 @@ public static void applyTriggerEffects( }); } - public static boolean shouldUseTriggerEffects(ControllerEntity controller, boolean inputSuppressed) { + public static boolean shouldUseTriggerEffects(ControllerImpl controller, boolean inputSuppressed) { return !inputSuppressed && MinecraftUtil.getScreen() == null && Minecraft.getInstance().player != null - && controller.dualSense().map(ds -> ds.settings().triggerEffects).orElse(false); + && controller.dualsense().map(ds -> ds.settings().triggerEffects).orElse(false); } } diff --git a/src/main/java/dev/isxander/controlify/controller/gyro/GyroButtonMode.java b/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroButtonMode.java similarity index 91% rename from src/main/java/dev/isxander/controlify/controller/gyro/GyroButtonMode.java rename to src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroButtonMode.java index 7b5c48cf5..880e7467d 100644 --- a/src/main/java/dev/isxander/controlify/controller/gyro/GyroButtonMode.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroButtonMode.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.gyro; +package dev.isxander.controlify.impl.controller.gyro; import com.mojang.serialization.Codec; import net.minecraft.util.StringRepresentable; diff --git a/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroCalibrator.java b/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroCalibrator.java new file mode 100644 index 000000000..934df4021 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroCalibrator.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.gyro; + +import org.joml.Vector3f; +import org.joml.Vector3fc; + +/** + * Implements rolling gyro calibration using exponential moving average + * for background bias estimation during periods of stable angular velocity. + * Bias application is handled by the input pipeline using the device settings. + */ +public class GyroCalibrator { + // Exponential moving average (EMA) alpha value - lower = slower adaptation, more stable + // 0.001 means it takes ~1000 samples to substantially update the bias estimate + private static final float EMA_ALPHA = 0.001f; + + // Maximum sample spread during a stationary window (rad/s), independent of zero-rate bias. + private static final float STATIONARY_THRESHOLD = 0.02f; + + // Minimum number of consecutive stationary samples before updating bias + private static final int MIN_STATIONARY_SAMPLES = 10; + + // Current bias estimate (offset to subtract from raw readings) + private final Vector3f biasEstimate = new Vector3f(0, 0, 0); + + // Counter for consecutive stationary samples + private int stationarySampleCount = 0; + private final Vector3f windowMin = new Vector3f(); + private final Vector3f windowMax = new Vector3f(); + + public void update(float x, float y, float z) { + if (!Float.isFinite(x) || !Float.isFinite(y) || !Float.isFinite(z)) { + this.stationarySampleCount = 0; + return; + } + // Check if the gyro appears to be stationary + boolean isStationary = isStationary(x, y, z); + + if (isStationary) { + this.stationarySampleCount++; + + // Only update bias estimate if we've been stationary long enough + if (this.stationarySampleCount >= MIN_STATIONARY_SAMPLES) { + // Update bias estimate using exponential moving average + // New bias = (1 - alpha) * old_bias + alpha * current_reading + this.biasEstimate.x = (1 - EMA_ALPHA) * this.biasEstimate.x + EMA_ALPHA * x; + this.biasEstimate.y = (1 - EMA_ALPHA) * this.biasEstimate.y + EMA_ALPHA * y; + this.biasEstimate.z = (1 - EMA_ALPHA) * this.biasEstimate.z + EMA_ALPHA * z; + } + } else { + // Reset stationary counter when motion is detected + this.stationarySampleCount = 1; + this.windowMin.set(x, y, z); + this.windowMax.set(x, y, z); + } + } + + /** + * Check the spread of raw samples, so hardware bias does not prevent calibration. + */ + private boolean isStationary(float x, float y, float z) { + if (this.stationarySampleCount == 0) { + this.windowMin.set(x, y, z); + this.windowMax.set(x, y, z); + } + this.windowMin.min(new Vector3f(x, y, z)); + this.windowMax.max(new Vector3f(x, y, z)); + return this.windowMax.x - this.windowMin.x < STATIONARY_THRESHOLD + && this.windowMax.y - this.windowMin.y < STATIONARY_THRESHOLD + && this.windowMax.z - this.windowMin.z < STATIONARY_THRESHOLD; + } + + /** + * Get the current bias estimate (for saving to config). + */ + public Vector3f getCurrentBias() { + return new Vector3f(this.biasEstimate); + } + + public void setBias(Vector3fc bias) { + this.biasEstimate.set(bias.x(), bias.y(), bias.z()); + this.stationarySampleCount = 0; + } + + public void reset() { + this.biasEstimate.set(0, 0, 0); + this.stationarySampleCount = 0; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroComponentImpl.java b/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroComponentImpl.java new file mode 100644 index 000000000..cc525817d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroComponentImpl.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.gyro; + +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.api.controller.gyro.GyroComponent; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.config.settings.profile.GyroSettings; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.input.pipeline.InputPipeline; +import dev.isxander.controlify.impl.input.pipeline.SensorProcessorStage; +import dev.isxander.controlify.impl.input.SensorType; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSensorSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.api.utils.event.EventSource; +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Nullable; +import org.joml.Vector3f; +import org.joml.Vector3fc; + +import java.util.Objects; +import java.util.List; + +public class GyroComponentImpl extends ControllerComponent implements GyroComponent { + public static final Identifier ID = CUtil.rl("gyro"); + private final InputPipeline inputPipeline; + private @Nullable EventSource.Subscription calibrationSubscription; + private @Nullable EventSource.Subscription sensorSubscription; + private DeviceSettings deviceSettings; + + private Vector3fc gyroState = new Vector3f(); + private static final long MAX_SAMPLE_AGE_NANOS = 50_000_000L; + private long lastStateTimeNanos; + private final GyroCalibrator calibrator = new GyroCalibrator(); + + private static final long CALIBRATION_SAVE_INTERVAL_NANOS = 5_000_000_000L; + private long lastCalibrationSaveTimeNanos; + private Vector3fc lastSavedBias = new Vector3f(); + + public GyroComponentImpl(InputPipeline inputPipeline) { + super(ID); + this.inputPipeline = Objects.requireNonNull(inputPipeline); + } + + @Override + public void attach(ControllerImpl controller) { + super.attach(controller); + + // Load the calibration from device settings + this.deviceSettings = Controlify.instance().config().getSettings() + .getOrCreateDeviceSettings(controller.uid()); + Vector3fc savedOffset = this.deviceSettings.gyroCalibration.offset; + + // Initialize the calibrator with the saved offset + calibrator.setBias(savedOffset); + lastSavedBias = new Vector3f(savedOffset); + this.lastCalibrationSaveTimeNanos = System.nanoTime(); + + this.inputPipeline.stageSensorProcessor().setProcessors(SensorType.GYROSCOPE, List.of( + new SensorProcessorStage.BiasCorrection(() -> this.deviceSettings.gyroCalibration.offset), + new SensorProcessorStage.LowPassFilter(() -> this.settings().lowPassCutoffHz) + )); + + this.calibrationSubscription = this.inputPipeline.sourceRawPhysicalSignals().subscribe(this::onRawSignal); + this.sensorSubscription = this.inputPipeline.sourceLogicalSignals().subscribe(this::onSignal); + } + + private void onRawSignal(PhysicalSignal signal) { + if (signal instanceof PhysicalSignal.Frame) { + this.gyroState = new Vector3f(); + } + if (signal instanceof PhysicalSensorSignal(_, _, SensorType sensorType, float x, float y, float z) + && sensorType == SensorType.GYROSCOPE) { + this.updateCalibration(x, y, z); + } + } + + private void updateCalibration(float x, float y, float z) { + this.calibrator.update(x, y, z); + // The raw tap runs before SensorProcessorStage, so its bias supplier sees this update immediately. + this.deviceSettings.gyroCalibration.offset = this.calibrator.getCurrentBias(); + + long now = System.nanoTime(); + if (now - this.lastCalibrationSaveTimeNanos >= CALIBRATION_SAVE_INTERVAL_NANOS) { + this.lastCalibrationSaveTimeNanos = now; + this.saveCalibrationIfChanged(); + } + } + + private void onSignal(LogicalSignal signal) { + if (signal instanceof LogicalSignal.SensorInterval(_, _, SensorType sensorType, float x, float y, float z) + && sensorType == SensorType.GYROSCOPE) { + // The pipeline has already calibrated, filtered and integrated these values into radians. + this.gyroState = new Vector3f(x, y, z); + this.lastStateTimeNanos = System.nanoTime(); + } + } + + /// Returns the latest integrated gyro interval in radians. + /// Cleared at the start of each frame; repeated reads within that frame are stable. + @Override + public Vector3fc getState() { + return System.nanoTime() - this.lastStateTimeNanos > MAX_SAMPLE_AGE_NANOS + ? new Vector3f() : this.gyroState; + } + + private void saveCalibrationIfChanged() { + Vector3fc currentBias = this.deviceSettings.gyroCalibration.offset; + + // Check if bias has changed significantly (more than 0.001 rad/s on any axis) + float threshold = 0.001f; + if (Math.abs(currentBias.x() - lastSavedBias.x()) > threshold || + Math.abs(currentBias.y() - lastSavedBias.y()) > threshold || + Math.abs(currentBias.z() - lastSavedBias.z()) > threshold) { + + // Settings update on every sample; persistence is checked at a fixed time interval. + Controlify.instance().config().markDirty(); + + lastSavedBias = new Vector3f(currentBias); + } + } + + public GyroCalibrator getCalibrator() { + return this.calibrator; + } + + public GyroSettings settings() { + return this.controller().settings().gyro; + } + + public GyroSettings defaultSettings() { + return this.controller().defaultSettings().gyro; + } + + @Override + public void close() { + if (this.calibrationSubscription != null) { + this.calibrationSubscription.close(); + this.calibrationSubscription = null; + } + if (this.sensorSubscription != null) { + this.sensorSubscription.close(); + this.sensorSubscription = null; + } + // The installed processors capture this component; the pipeline may outlive it. + this.inputPipeline.stageSensorProcessor().setProcessors(SensorType.GYROSCOPE, List.of()); + if (this.deviceSettings != null) { + this.saveCalibrationIfChanged(); + } + super.close(); + } + +} diff --git a/src/main/java/dev/isxander/controlify/controller/gyro/GyroYawMode.java b/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroYawMode.java similarity index 90% rename from src/main/java/dev/isxander/controlify/controller/gyro/GyroYawMode.java rename to src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroYawMode.java index ab31977bf..2214b10a5 100644 --- a/src/main/java/dev/isxander/controlify/controller/gyro/GyroYawMode.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/gyro/GyroYawMode.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.gyro; +package dev.isxander.controlify.impl.controller.gyro; import com.mojang.serialization.Codec; import net.minecraft.util.StringRepresentable; diff --git a/src/main/java/dev/isxander/controlify/controller/haptic/CompleteSoundData.java b/src/main/java/dev/isxander/controlify/impl/controller/haptic/CompleteSoundData.java similarity index 80% rename from src/main/java/dev/isxander/controlify/controller/haptic/CompleteSoundData.java rename to src/main/java/dev/isxander/controlify/impl/controller/haptic/CompleteSoundData.java index 91beaf208..a287508b0 100644 --- a/src/main/java/dev/isxander/controlify/controller/haptic/CompleteSoundData.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/haptic/CompleteSoundData.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.haptic; +package dev.isxander.controlify.impl.controller.haptic; import javax.sound.sampled.AudioFormat; diff --git a/src/main/java/dev/isxander/controlify/controller/haptic/HDHapticComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/haptic/HDHapticComponent.java similarity index 90% rename from src/main/java/dev/isxander/controlify/controller/haptic/HDHapticComponent.java rename to src/main/java/dev/isxander/controlify/impl/controller/haptic/HDHapticComponent.java index 08d072574..35ba1b068 100644 --- a/src/main/java/dev/isxander/controlify/controller/haptic/HDHapticComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/haptic/HDHapticComponent.java @@ -4,15 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.haptic; +package dev.isxander.controlify.impl.controller.haptic; import com.mojang.blaze3d.audio.SoundBuffer; -import dev.isxander.controlify.config.settings.profile.HDHapticSettings; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; +import dev.isxander.controlify.impl.config.settings.profile.HDHapticSettings; +import dev.isxander.controlify.impl.controller.ControllerComponent; import dev.isxander.controlify.mixins.feature.hdhaptics.SoundBufferAccessor; import dev.isxander.controlify.mixins.feature.hdhaptics.SoundEngineAccessor; import dev.isxander.controlify.mixins.feature.hdhaptics.SoundManagerAccessor; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.sounds.SoundBufferLibrary; import net.minecraft.client.sounds.SoundEngine; @@ -28,7 +28,7 @@ import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; -public class HDHapticComponent extends ECSComponentImpl { +public class HDHapticComponent extends ControllerComponent { public static final Identifier ID = CUtil.rl("hd_haptics"); private Consumer playHapticConsumer; @@ -40,6 +40,7 @@ public class HDHapticComponent extends ECSComponentImpl { private static final Map hapticData = new HashMap<>(); public HDHapticComponent() { + super(ID); this.randomSource = RandomSource.create(); } @@ -105,9 +106,4 @@ public HDHapticSettings settings() { public HDHapticSettings defaultSettings() { return this.controller().defaultSettings().hdHaptic; } - - @Override - public Identifier id() { - return ID; - } } diff --git a/src/main/java/dev/isxander/controlify/controller/haptic/HapticEffects.java b/src/main/java/dev/isxander/controlify/impl/controller/haptic/HapticEffects.java similarity index 51% rename from src/main/java/dev/isxander/controlify/controller/haptic/HapticEffects.java rename to src/main/java/dev/isxander/controlify/impl/controller/haptic/HapticEffects.java index ae911302f..09d76caf2 100644 --- a/src/main/java/dev/isxander/controlify/controller/haptic/HapticEffects.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/haptic/HapticEffects.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.haptic; +package dev.isxander.controlify.impl.controller.haptic; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; public final class HapticEffects { - public static final Identifier NAVIGATE = CUtil.rl("haptics/navigate.ogg"); + public static final Identifier NAVIGATE = CUtil.rl("controller/haptics/navigate.ogg"); } diff --git a/src/main/java/dev/isxander/controlify/impl/controller/input/InputComponentImpl.java b/src/main/java/dev/isxander/controlify/impl/controller/input/InputComponentImpl.java new file mode 100644 index 000000000..131c7460f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/input/InputComponentImpl.java @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.input; + +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.action.ActionSpec; +import dev.isxander.controlify.api.controller.input.InputComponent; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ActionDispatcher; +import dev.isxander.controlify.impl.action.ActionImpl; +import dev.isxander.controlify.impl.action.ActionSpecRegistryImpl; +import dev.isxander.controlify.impl.action.activator.ActivatorConfig; +import dev.isxander.controlify.impl.action.defaults.DefaultBindProvider; +import dev.isxander.controlify.impl.action.proxy.ActionProxyManagerImpl; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.config.settings.profile.InputSettings; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.input.mapping.ControllerMapping; +import dev.isxander.controlify.impl.input.mapping.ControllerMappingManager; +import dev.isxander.controlify.impl.input.pipeline.GestureTimings; +import dev.isxander.controlify.impl.input.pipeline.InputPipeline; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.*; + +public class InputComponentImpl extends ControllerComponent implements InputComponent { + public static final Identifier ID = CUtil.rl("input"); + + private final Identifier controllerType; + private DeviceSettings deviceSettings; + private ControllerMapping controllerMapping; + + private final InputPipeline inputPipeline; + private final ActionDispatcher actionDispatcher; + + private final Map actions; + + private final Set physicalButtons, physicalAxes; + private final boolean isGamepad; + + private EventSource.Subscription actionDispatcherSub; + + public InputComponentImpl( + Identifier controllerType, + InputPipeline inputPipeline, + Set physicalButtons, + Set physicalAxes, + boolean isGamepad + ) { + super(ID); + this.controllerType = controllerType; + this.inputPipeline = inputPipeline; + + this.physicalButtons = Set.copyOf(physicalButtons); + this.physicalAxes = Set.copyOf(physicalAxes); + this.isGamepad = isGamepad; + + this.actionDispatcher = new ActionDispatcher(); + this.actions = new LinkedHashMap<>(); + } + + @Override + public void attach(ControllerImpl controller) { + super.attach(controller); + + this.deviceSettings = Controlify.instance().config().getSettings() + .getOrCreateDeviceSettings(controller.uid()); + + this.reloadControllerMapping(); + this.inputPipeline.stageDeadzone().setDeadzoneSupplier(axis -> settings().sensitivity.getDeadzone((Identifier) axis)); + + this.inputPipeline.stageInputDomainExpansion().setThresholdSupplier(() -> settings().buttonActivationThreshold); + this.inputPipeline.stagePhysicalToLogical().setTimingsSupplier(() -> { + var gestures = settings().gestures; + return GestureTimings.ofMillis(gestures.tapMaxMs, gestures.doubleTapGapMs, gestures.holdMs); + }); + + this.reloadActions(); + this.actionDispatcherSub = this.inputPipeline.sourceLogicalSignals().subscribe(signal -> { + this.actionDispatcher.accept(signal); + ActionProxyManagerImpl.INSTANCE.update(this.controller()); + }); + } + + @Override + public @Nullable ActionImpl getAction(@NonNull Identifier actionId) { + return this.actions.get(actionId); + } + + public Collection getAllActions() { + return Collections.unmodifiableCollection(this.actions.values()); + } + + public ActionDispatcher actionDispatcher() { + return this.actionDispatcher; + } + + public InputPipeline inputPipeline() { + return this.inputPipeline; + } + + public ControllerMapping controllerMapping() { + return this.controllerMapping; + } + + /// Refreshes resource-defined mappings while preserving a device-specific override. + public void reloadControllerMapping() { + ControllerMapping controllerMapping = this.deviceSettings.mapping; + if (controllerMapping == null) { + ControllerMappingManager controllerMappingManager = Controlify.instance().controllerMappingManager(); + controllerMapping = this.controller().info().type().mappingId() + .flatMap(controllerMappingManager::getMappingById) + .orElse(null); + } + if (controllerMapping == null) { + controllerMapping = this.isGamepad + ? ControllerMapping.GAMEPAD_DEADZONES_ONLY_MAPPING + : ControllerMapping.NO_MAPPING; + } + if (controllerMapping.equals(this.controllerMapping)) { + return; + } + this.actionDispatcher.resetInputState(); + ActionProxyManagerImpl.INSTANCE.update(this.controller()); + this.controllerMapping = controllerMapping; + this.inputPipeline.setControllerMapping(controllerMapping); + } + + public void resetActions() { + this.actionDispatcher.resetActions(); + ActionProxyManagerImpl.INSTANCE.update(this.controller()); + } + + public void reloadDefaultInputs() { + DefaultBindProvider provider = Controlify.instance().defaultBindManager() + .getDefaultBindProvider(this.controllerType); + for (ActionImpl action : this.actions.values()) { + action.setDefaultBindings(provider.getDefaultBindings(action.spec().id())); + if (!settings().bindings.bindings.containsKey(action.spec().id())) { + action.setBindings(action.defaultBindings()); + } + } + } + + public void reloadActions() { + ActionProxyManagerImpl.INSTANCE.release(this.controller()); + this.actions.clear(); + this.actionDispatcher.clearActions(); + + for (ActionSpec spec : ActionSpecRegistryImpl.INSTANCE.getActionSpecs()) { + ActionImpl action = this.createAction(spec); + this.actions.put(spec.id(), action); + this.actionDispatcher.addAction(action); + } + } + + public void onGuiNavigate() { + this.inputPipeline.stagePhysicalToLogical().onGuiNavigate(); + } + + public InputSettings settings() { + return this.controller().settings().input; + } + + public InputSettings defaultSettings() { + return this.controller().defaultSettings().input; + } + + @Override + public boolean isGamepad() { + return this.isGamepad; + } + + public Set physicalButtons() { + return this.physicalButtons; + } + + public Set physicalAxes() { + return this.physicalAxes; + } + + @Override + public void close() { + ActionProxyManagerImpl.INSTANCE.release(this.controller()); + super.close(); + + this.actionDispatcherSub.close(); + this.actionDispatcherSub = null; + } + + private ActionImpl createAction(ActionSpec spec) { + DefaultBindProvider bindProvider = Controlify.instance().defaultBindManager() + .getDefaultBindProvider(this.controllerType); + List defaultBindings = bindProvider.getDefaultBindings(spec.id()); + List currentBindings = settings().bindings.resolve(spec.id(), defaultBindings); + + ActivatorConfig activatorConfig = Controlify.instance().activatorConfigManager() + .getConfig(spec.id()); + + ActionImpl action = new ActionImpl( + spec, + currentBindings, + defaultBindings, + activatorConfig, + this.actionDispatcher + ); + action.reset(); + + return action; + } +} diff --git a/src/main/java/dev/isxander/controlify/controller/led/LEDComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/led/LEDComponent.java similarity index 86% rename from src/main/java/dev/isxander/controlify/controller/led/LEDComponent.java rename to src/main/java/dev/isxander/controlify/impl/controller/led/LEDComponent.java index 3d78419ed..820faaddf 100644 --- a/src/main/java/dev/isxander/controlify/controller/led/LEDComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/led/LEDComponent.java @@ -4,22 +4,22 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.led; +package dev.isxander.controlify.impl.controller.led; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; -import org.jetbrains.annotations.ApiStatus; import java.util.Arrays; -public class LEDComponent extends ECSComponentImpl { +public class LEDComponent extends ControllerComponent { public static final Identifier ID = CUtil.rl("led"); private final int[] ledColors; private boolean dirty = false; public LEDComponent(int ledCount) { + super(ID); this.ledColors = new int[ledCount]; } @@ -77,15 +77,9 @@ public int getCount() { return ledColors.length; } - @ApiStatus.Internal public boolean consumeDirty() { boolean old = this.dirty; this.dirty = false; return old; } - - @Override - public Identifier id() { - return ID; - } } diff --git a/src/main/java/dev/isxander/controlify/controller/misc/BluetoothDeviceComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/misc/BluetoothDeviceComponent.java similarity index 54% rename from src/main/java/dev/isxander/controlify/controller/misc/BluetoothDeviceComponent.java rename to src/main/java/dev/isxander/controlify/impl/controller/misc/BluetoothDeviceComponent.java index 73171ce2b..084e35e4f 100644 --- a/src/main/java/dev/isxander/controlify/controller/misc/BluetoothDeviceComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/misc/BluetoothDeviceComponent.java @@ -4,16 +4,20 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.misc; +package dev.isxander.controlify.impl.controller.misc; -import dev.isxander.controlify.config.settings.profile.BluetoothDeviceSettings; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.config.settings.profile.BluetoothDeviceSettings; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; -public class BluetoothDeviceComponent extends ECSComponentImpl { +public class BluetoothDeviceComponent extends ControllerComponent { public static final Identifier ID = CUtil.rl("bluetooth"); + public BluetoothDeviceComponent() { + super(ID); + } + public BluetoothDeviceSettings settings() { return this.controller().settings().bluetoothDevice; } @@ -21,9 +25,4 @@ public BluetoothDeviceSettings settings() { public BluetoothDeviceSettings defaultSettings() { return this.controller().defaultSettings().bluetoothDevice; } - - @Override - public Identifier id() { - return ID; - } } diff --git a/src/main/java/dev/isxander/controlify/impl/controller/misc/DriverNameComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/misc/DriverNameComponent.java new file mode 100644 index 000000000..83b6c630d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/misc/DriverNameComponent.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.misc; + +import dev.isxander.controlify.impl.controller.SingleValueControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; + +public class DriverNameComponent extends SingleValueControllerComponent { + public static final Identifier ID = CUtil.rl("driver_name"); + + public DriverNameComponent(String value) { + super(ID, value); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/misc/UIDComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/misc/UIDComponent.java new file mode 100644 index 000000000..f8e0bf97f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/misc/UIDComponent.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.misc; + +import dev.isxander.controlify.impl.controller.SingleValueControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; + +public class UIDComponent extends SingleValueControllerComponent { + public static final Identifier ID = CUtil.rl("uid"); + + public UIDComponent(String value) { + super(ID, value); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/rumble/ContinuousRumbleEffectImpl.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/ContinuousRumbleEffectImpl.java new file mode 100644 index 000000000..8382e612c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/ContinuousRumbleEffectImpl.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.rumble; + +import dev.isxander.controlify.api.controller.rumble.ContinuousRumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import net.minecraft.client.Minecraft; +import net.minecraft.util.Mth; +import net.minecraft.world.phys.Vec3; +import org.jspecify.annotations.NonNull; + +import java.util.Objects; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.function.IntFunction; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; + +public final class ContinuousRumbleEffectImpl implements ContinuousRumbleEffect { + private final IntFunction state; + private final BooleanSupplier stop; + private final int timeout, minimum; + private final UnaryOperator attenuation; + + public ContinuousRumbleEffectImpl() { + this(_ -> RumbleState.NONE, () -> false, 0, 0, value -> value); + } + + private ContinuousRumbleEffectImpl(IntFunction state, BooleanSupplier stop, + int timeout, int minimum, UnaryOperator attenuation) { + this.state = Objects.requireNonNull(state); + this.stop = stop; + this.timeout = timeout; + this.minimum = minimum; + this.attenuation = attenuation; + } + + @Override + public int timeoutTicks() { + return this.timeout; + } + + @Override + public int minimumTicks() { + return this.minimum; + } + + @Override + public boolean shouldStop() { + return this.stop.getAsBoolean(); + } + + @Override + public @NonNull RumbleState stateAt(int tick) { + return this.attenuation.apply(Objects.requireNonNull(this.state.apply(tick))); + } + + @Override + public @NonNull ContinuousRumbleEffect byTick(@NonNull IntFunction state) { + return new ContinuousRumbleEffectImpl(state, this.stop, this.timeout, this.minimum, this.attenuation); + } + + @Override + public @NonNull ContinuousRumbleEffect constant(float strong, float weak) { + return byTick(_ -> new RumbleState(strong, weak)); + } + + @Override + public @NonNull ContinuousRumbleEffect stopCondition(@NonNull BooleanSupplier condition) { + Objects.requireNonNull(condition); + return new ContinuousRumbleEffectImpl( + this.state, + () -> this.stop.getAsBoolean() || condition.getAsBoolean(), + this.timeout, + this.minimum, + this.attenuation + ); + } + + @Override + public @NonNull ContinuousRumbleEffect timeout(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("Timeout cannot be negative"); + } + return new ContinuousRumbleEffectImpl(this.state, this.stop, ticks, this.minimum, this.attenuation); + } + + @Override + public @NonNull ContinuousRumbleEffect minTime(int ticks) { + if (ticks < 0) { + throw new IllegalArgumentException("Minimum duration cannot be negative"); + } + return new ContinuousRumbleEffectImpl(this.state, this.stop, this.timeout, ticks, this.attenuation); + } + + @Override + public @NonNull ContinuousRumbleEffect inWorld( + @NonNull Supplier location, + float minMagnitude, float maxMagnitude, + float range, + @NonNull Function falloff + ) { + Objects.requireNonNull(location); + Objects.requireNonNull(falloff); + if (!Float.isFinite(range) || range <= 0) { + throw new IllegalArgumentException("Range must be finite and positive"); + } + if (!Float.isFinite(minMagnitude) || !Float.isFinite(maxMagnitude) || minMagnitude < 0 || maxMagnitude < minMagnitude) { + throw new IllegalArgumentException("Magnitudes must be finite, nonnegative and ordered"); + } + return new ContinuousRumbleEffectImpl( + this.state, + () -> this.stop.getAsBoolean() || Minecraft.getInstance().getCameraEntity() == null, + this.timeout, this.minimum, value -> { + var camera = Minecraft.getInstance().getCameraEntity(); + if (camera == null) { + return RumbleState.NONE; + } + float distance = (float) Mth.clamp(camera.distanceToSqr(location.get()) / ((double) range * range), 0, 1); + return value.mul(Mth.lerp(falloff.apply(1 - distance), minMagnitude, maxMagnitude)); + } + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/rumble/FiniteRumbleEffectImpl.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/FiniteRumbleEffectImpl.java new file mode 100644 index 000000000..4e3fc98a8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/FiniteRumbleEffectImpl.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.rumble; + +import dev.isxander.controlify.api.controller.rumble.FiniteRumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import org.jspecify.annotations.NonNull; + +import java.util.Arrays; +import java.util.Objects; +import java.util.function.BooleanSupplier; +import java.util.function.Function; +import java.util.function.IntFunction; + +public final class FiniteRumbleEffectImpl implements FiniteRumbleEffect { + private final RumbleState[] frames; + private final BooleanSupplier stop; + + public FiniteRumbleEffectImpl() { + this(new RumbleState[0], () -> false); + } + + private FiniteRumbleEffectImpl(RumbleState[] frames, BooleanSupplier stop) { + this.frames = frames; + this.stop = stop; + } + + @Override + public int durationTicks() { + return this.frames.length; + } + + @Override + public @NonNull RumbleState stateAt(int tick) { + return this.frames[tick]; + } + + @Override + public boolean shouldStop() { + return this.stop.getAsBoolean(); + } + + @Override + public @NonNull FiniteRumbleEffect stopCondition(@NonNull BooleanSupplier condition) { + Objects.requireNonNull(condition); + return new FiniteRumbleEffectImpl(this.frames, () -> this.stop.getAsBoolean() || condition.getAsBoolean()); + } + + @Override + public @NonNull FiniteRumbleEffect byTick(@NonNull IntFunction state, int durationTicks) { + Objects.requireNonNull(state); + if (durationTicks < 0) { + throw new IllegalArgumentException("Duration cannot be negative"); + } + RumbleState[] frames = new RumbleState[durationTicks]; + for (int i = 0; i < frames.length; i++) { + frames[i] = Objects.requireNonNull(state.apply(i)); + } + return new FiniteRumbleEffectImpl(frames, this.stop); + } + + @Override + public @NonNull FiniteRumbleEffect byTime(@NonNull Function state, int durationTicks) { + Objects.requireNonNull(state); + return byTick(tick -> state.apply((float) tick / durationTicks), durationTicks); + } + + @Override + public @NonNull FiniteRumbleEffect constant(float strong, float weak, int durationTicks) { + return byTick(_ -> new RumbleState(strong, weak), durationTicks); + } + + @Override + public @NonNull FiniteRumbleEffect then(@NonNull FiniteRumbleEffect next) { + Objects.requireNonNull(next); + int length = next.durationTicks(); + if (length < 0) { + throw new IllegalArgumentException("Duration cannot be negative"); + } + + RumbleState[] frames = Arrays.copyOf(this.frames, Math.addExact(this.frames.length, length)); + for (int i = 0; i < length; i++) { + frames[this.frames.length + i] = Objects.requireNonNull(next.stateAt(i)); + } + return new FiniteRumbleEffectImpl(frames, this.stop); + } + + @Override + public @NonNull FiniteRumbleEffect repeat(int count) { + if (count <= 0) { + throw new IllegalArgumentException("Repeat count must be positive"); + } + RumbleState[] frames = new RumbleState[Math.multiplyExact(this.frames.length, count)]; + for (int i = 0; i < count && this.frames.length > 0; i++) { + System.arraycopy(this.frames, 0, frames, i * this.frames.length, this.frames.length); + } + return new FiniteRumbleEffectImpl(frames, this.stop); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleComponentImpl.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleComponentImpl.java new file mode 100644 index 000000000..ed6469e50 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleComponentImpl.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.rumble; + +import dev.isxander.controlify.api.controller.rumble.RumbleComponent; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.config.settings.profile.RumbleSettings; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import java.util.Optional; + +public class RumbleComponentImpl extends ControllerComponent implements RumbleComponent { + public static final Identifier ID = CUtil.rl("rumble"); + + private RumbleState state = null; + private final RumbleManager rumbleManager; + + public RumbleComponentImpl() { + super(ID); + this.rumbleManager = new RumbleManager(this); + } + + @Override + public @NonNull RumbleEffectInstance playEffect(@NonNull Identifier effectId, @NonNull RumbleEffect effect) { + return this.rumbleManager.play(effectId, effect); + } + + public void queueRumble(RumbleState state) { + if (settings().enabled || state.isZero()) { + this.state = state; + } + } + + public Optional consumeRumble() { + RumbleState state = this.state; + this.state = null; + return Optional.ofNullable(state); + } + + public RumbleManager rumbleManager() { + return this.rumbleManager; + } + + public RumbleSettings settings() { + return this.controller().settings().rumble; + } + + public RumbleSettings defaultSettings() { + return this.controller().defaultSettings().rumble; + } + +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleEffectInstanceImpl.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleEffectInstanceImpl.java new file mode 100644 index 000000000..624767a8b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleEffectInstanceImpl.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.rumble; + +import dev.isxander.controlify.api.controller.rumble.ContinuousRumbleEffect; +import dev.isxander.controlify.api.controller.rumble.FiniteRumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import net.minecraft.resources.Identifier; + +import java.util.Objects; + +/// Playback state belongs to the handle, never to the reusable definition. +public final class RumbleEffectInstanceImpl implements RumbleEffectInstance { + private final Identifier effectId; + private final RumbleEffect effect; + private int tick, inactivity; + private boolean started, finished, stopRequested; + private RumbleState state = RumbleState.NONE; + + public RumbleEffectInstanceImpl(Identifier effectId, RumbleEffect effect) { + this.effectId = Objects.requireNonNull(effectId); + this.effect = Objects.requireNonNull(effect); + } + + public Identifier effectId() { + return effectId; + } + + @Override + public boolean hasStarted() { + return this.started || this.finished; + } + + @Override + public boolean hasFinished() { + return this.finished; + } + + public RumbleState currentState() { + return this.state; + } + + @Override + public boolean stop() { + if (this.finished) { + return false; + } + this.finished = true; + this.state = RumbleState.NONE; + return true; + } + + @Override + public boolean keepAlive() { + if (this.finished || !(this.effect instanceof ContinuousRumbleEffect)) { + return false; + } + this.inactivity = 0; + return true; + } + + public void tick() { + if (this.finished) { + return; + } + this.stopRequested |= this.effect.shouldStop(); + switch (this.effect) { + case FiniteRumbleEffect finite -> { + if (this.stopRequested || this.tick >= finite.durationTicks()) { + this.stop(); + return; + } + } + case ContinuousRumbleEffect continuous -> { + if (this.tick >= continuous.minimumTicks() && (stopRequested || continuous.timeoutTicks() > 0 && inactivity >= continuous.timeoutTicks())) { + this.stop(); + return; + } + } + } + + this.state = Objects.requireNonNull(effect.stateAt(tick)); + this.started = true; + if (this.tick < Integer.MAX_VALUE) { + tick++; + } + if (this.inactivity < Integer.MAX_VALUE) { + this.inactivity++; + } + + if (this.effect instanceof FiniteRumbleEffect finite && this.tick >= finite.durationTicks()) + this.finished = true; + if (this.effect instanceof ContinuousRumbleEffect continuous && this.tick >= continuous.minimumTicks() + && (this.stopRequested || continuous.timeoutTicks() > 0 && this.inactivity >= continuous.timeoutTicks())) { + this.finished = true; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleManager.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleManager.java new file mode 100644 index 000000000..1d5c5ab4b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleManager.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.rumble; + +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.Controlify; +import net.minecraft.resources.Identifier; + +import java.util.ArrayList; +import java.util.List; + +public class RumbleManager { + private final RumbleComponentImpl controller; + private final List effectQueue = new ArrayList<>(); + + private boolean silent, wasSilent; + + public RumbleManager(RumbleComponentImpl controller) { + this.controller = controller; + } + + public RumbleEffectInstance play(Identifier effectId, RumbleEffect effect) { + return play(effectId, RumbleSource.MASTER, effect); + } + + /// Internal entry point for server-specified fallback sources. + public RumbleEffectInstance play(Identifier effectId, RumbleSource source, RumbleEffect effect) { + var instance = new RumbleEffectInstanceImpl(effectId, effect); + this.effectQueue.add(new PlayingEffect(source, instance)); + return instance; + } + + public void tick() { + this.effectQueue.removeIf(e -> e.effect().hasFinished()); + this.effectQueue.forEach(e -> e.effect().tick()); + + if (effectQueue.isEmpty()) { + clearRumble(); + return; + } + + float strong = 0f, weak = 0f; + RumbleSourceManager sourceManager = Controlify.instance().rumbleSourceManager(); + for (PlayingEffect effect : effectQueue) { + RumbleSource source = sourceManager.getSource(effect.effect().effectId(), effect.source()); + RumbleState effectState = controller.settings().applyRumbleStrength(effect.effect().currentState(), source); + strong = Math.max(strong, effectState.strong()); + weak = Math.max(weak, effectState.weak()); + } + RumbleState state = new RumbleState(strong, weak); + + if (state.isZero()) { + clearRumble(); + return; + } + + if (silent || !controller.settings().enabled) { + clearRumble(); + } else { + controller.queueRumble(state); + wasSilent = false; + } + } + + private void clearRumble() { + if (wasSilent) + return; + + controller.queueRumble(RumbleState.NONE); + wasSilent = true; + } + + public void clearEffects() { + for (PlayingEffect instance : effectQueue) { + instance.effect().stop(); + } + effectQueue.clear(); + clearRumble(); + } + + public void setSilent(boolean silent) { + this.silent = silent; + } + + public boolean isPlaying() { + return !effectQueue.isEmpty(); + } + + private record PlayingEffect(RumbleSource source, RumbleEffectInstanceImpl effect) { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleSource.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleSource.java new file mode 100644 index 000000000..3bc24ffe5 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleSource.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.rumble; + +import com.mojang.serialization.Codec; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; + +import java.util.Objects; + +/// A source identifier, with no registration or availability implied by construction. +/// Available sources are determined by RumbleSourceManager's resource-pack assignments. +public record RumbleSource(Identifier id) { + public static final Codec CODEC = Identifier.CODEC + .xmap(RumbleSource::new, RumbleSource::id); + + /// The global gain/fallback source; this constant does not declare its availability. + public static final RumbleSource MASTER = new RumbleSource(CUtil.rl("master")); + + public RumbleSource { + Objects.requireNonNull(id); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleSourceManager.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleSourceManager.java new file mode 100644 index 000000000..5753e2a33 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/RumbleSourceManager.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.rumble; + +import com.google.gson.JsonParser; +import com.mojang.serialization.Codec; +import com.mojang.serialization.JsonOps; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; + +import java.io.BufferedReader; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +/// Merges effect-to-source assignments from assets/controlify/controller/rumble_sources.json. +/// Higher-priority packs override individual effect IDs. The distinct source IDs in +/// the merged assignments define all available sources. Unassigned effects use their +/// caller-specified fallback if available, otherwise master. +public final class RumbleSourceManager implements SimpleControlifyReloadListener> { + private static final Identifier DEFINITIONS = CUtil.rl("controller/rumble_sources.json"); + private static final Codec> CODEC = Codec.unboundedMap(Identifier.CODEC, RumbleSource.CODEC); + + private volatile Map sourcesByEffect = Map.of(); + + public RumbleSource getSource(Identifier effectId, RumbleSource fallback) { + Map assignments = this.sourcesByEffect; + RumbleSource assigned = assignments.get(effectId); + if (assigned != null) return assigned; + return assignments.containsValue(fallback) ? fallback : RumbleSource.MASTER; + } + + /// Every distinct source named in the loaded assignments, including custom namespaces. + /// No built-in list is added; removing assignments also removes otherwise unused sources. + public List sources() { + return this.sourcesByEffect.values().stream() + .distinct() + .sorted(Comparator.comparing(source -> source.id().toString())) + .toList(); + } + + @Override + public CompletableFuture> load(ResourceManager manager, Executor executor) { + return CompletableFuture.supplyAsync(() -> { + Map assignments = new HashMap<>(); + // Resource stacks are ordered from lowest to highest pack priority. + for (Resource resource : manager.getResourceStack(DEFINITIONS)) { + try (BufferedReader reader = resource.openAsReader()) { + CODEC.parse(JsonOps.INSTANCE, JsonParser.parseReader(reader)) + .resultOrPartial(error -> CUtil.LOGGER.error( + "Failed to parse rumble source assignments from pack '{}': {}", + resource.sourcePackId(), error + )) + .ifPresent(assignments::putAll); + } catch (Exception e) { + CUtil.LOGGER.error("Failed to load rumble source assignments from pack '{}'", resource.sourcePackId(), e); + } + } + return Map.copyOf(assignments); + }, executor); + } + + @Override + public CompletableFuture apply(Map data, ResourceManager manager, Executor executor) { + return CompletableFuture.runAsync(() -> this.sourcesByEffect = Map.copyOf(data), executor); + } + + @Override + public Identifier getReloadId() { + return CUtil.rl("rumble_sources"); + } +} diff --git a/src/main/java/dev/isxander/controlify/controller/rumble/TriggerRumbleComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/TriggerRumbleComponent.java similarity index 62% rename from src/main/java/dev/isxander/controlify/controller/rumble/TriggerRumbleComponent.java rename to src/main/java/dev/isxander/controlify/impl/controller/rumble/TriggerRumbleComponent.java index 916196661..a3493167f 100644 --- a/src/main/java/dev/isxander/controlify/controller/rumble/TriggerRumbleComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/TriggerRumbleComponent.java @@ -4,21 +4,25 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.rumble; +package dev.isxander.controlify.impl.controller.rumble; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.rumble.TriggerRumbleState; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.api.controller.rumble.TriggerRumbleState; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.NotNull; import java.util.Optional; -public class TriggerRumbleComponent extends ECSComponentImpl { +public class TriggerRumbleComponent extends ControllerComponent { public static final Identifier ID = CUtil.rl("trigger_rumble"); private TriggerRumbleState state = null; + public TriggerRumbleComponent() { + super(ID); + } + public void queueTriggerRumble(@NotNull TriggerRumbleState state) { this.state = state; } @@ -28,9 +32,4 @@ public Optional consumeTriggerRumble() { this.state = null; return Optional.ofNullable(state); } - - @Override - public Identifier id() { - return ID; - } } diff --git a/src/main/java/dev/isxander/controlify/rumble/effects/UseItemEffectHolder.java b/src/main/java/dev/isxander/controlify/impl/controller/rumble/effects/UseItemEffectHolder.java similarity index 50% rename from src/main/java/dev/isxander/controlify/rumble/effects/UseItemEffectHolder.java rename to src/main/java/dev/isxander/controlify/impl/controller/rumble/effects/UseItemEffectHolder.java index 5e2b86a4c..d660ebb46 100644 --- a/src/main/java/dev/isxander/controlify/rumble/effects/UseItemEffectHolder.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/rumble/effects/UseItemEffectHolder.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.rumble.effects; +package dev.isxander.controlify.impl.controller.rumble.effects; -import dev.isxander.controlify.rumble.ContinuousRumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; import org.jetbrains.annotations.Nullable; public interface UseItemEffectHolder { - @Nullable ContinuousRumbleEffect controlify$getUseItemEffect(); + @Nullable RumbleEffectInstance controlify$getUseItemEffect(); } diff --git a/src/main/java/dev/isxander/controlify/impl/controller/steamdeck/SteamDeckComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/steamdeck/SteamDeckComponent.java new file mode 100644 index 000000000..d5ccf8521 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controller/steamdeck/SteamDeckComponent.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controller.steamdeck; + +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; + +public class SteamDeckComponent extends ControllerComponent { + public static final Identifier ID = CUtil.rl("steam_deck"); + + public SteamDeckComponent() { + super(ID); + } +} diff --git a/src/main/java/dev/isxander/controlify/controller/touchpad/TouchpadComponent.java b/src/main/java/dev/isxander/controlify/impl/controller/touchpad/TouchpadComponent.java similarity index 60% rename from src/main/java/dev/isxander/controlify/controller/touchpad/TouchpadComponent.java rename to src/main/java/dev/isxander/controlify/impl/controller/touchpad/TouchpadComponent.java index ba47c98a3..e202afd4e 100644 --- a/src/main/java/dev/isxander/controlify/controller/touchpad/TouchpadComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/touchpad/TouchpadComponent.java @@ -4,27 +4,23 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.touchpad; +package dev.isxander.controlify.impl.controller.touchpad; -import dev.isxander.controlify.controller.impl.ECSComponentImpl; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.controller.ControllerComponent; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; -public class TouchpadComponent extends ECSComponentImpl { +public class TouchpadComponent extends ControllerComponent { public static final Identifier ID = CUtil.rl("touchpad"); private final Touchpads touchpads; public TouchpadComponent(Touchpads touchpads) { + super(ID); this.touchpads = touchpads; } public Touchpads.Touchpad[] touchpads() { return touchpads.touchpads(); } - - @Override - public Identifier id() { - return ID; - } } diff --git a/src/main/java/dev/isxander/controlify/controller/touchpad/Touchpads.java b/src/main/java/dev/isxander/controlify/impl/controller/touchpad/Touchpads.java similarity index 95% rename from src/main/java/dev/isxander/controlify/controller/touchpad/Touchpads.java rename to src/main/java/dev/isxander/controlify/impl/controller/touchpad/Touchpads.java index 2c403902c..45148dd1f 100644 --- a/src/main/java/dev/isxander/controlify/controller/touchpad/Touchpads.java +++ b/src/main/java/dev/isxander/controlify/impl/controller/touchpad/Touchpads.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.touchpad; +package dev.isxander.controlify.impl.controller.touchpad; import org.joml.Vector2fc; diff --git a/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerBackend.java b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerBackend.java new file mode 100644 index 000000000..e8477eabf --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerBackend.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controllermanager; + +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerInfo; +import dev.isxander.controlify.impl.hid.HIDID; + +/// A controller backend is responsible for listening to controller events +/// and creating controllers using the referenced {@link ControllerManager controller manager}. +public abstract class ControllerBackend { + protected final Controlify controlify; + protected final ControllerManager controllerManager; + + public ControllerBackend(Controlify controlify, ControllerManager controllerManager) { + this.controlify = controlify; + this.controllerManager = controllerManager; + } + + /// Called once and allows the backend to do any first-time setup + /// before beginning to poll events and discover controllers. + public abstract void setup(); + + /// Called once after {@link setup}, discovers already-connected + /// controllers before ever polling events. + public abstract void discoverControllers(); + + /// Opens a controller with an already identified type and adds it to the manager. + /// Also used after closing a controller whose resource-defined type changed. + public abstract void createAndAddController(ControllerInfo info, boolean hotplugged); + + /// Called once per frame, polls events from the backend and processes + /// them, potentially creating controllers and adding them to {@link ControllerManager}. + public abstract void pollEvents(); + + protected ControllerType getControllerType(HIDID hidId) { + return this.controlify.controllerTypeManager().getControllerType(hidId); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerCoordinator.java b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerCoordinator.java new file mode 100644 index 000000000..835bc0b01 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerCoordinator.java @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controllermanager; + +import dev.isxander.controlify.api.ControlifyApi; +import dev.isxander.controlify.api.InputMode; +import dev.isxander.controlify.api.controllermanager.ControllerEvents; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ActionLayer; +import dev.isxander.controlify.impl.action.ActionLayerRegistryImpl; +import dev.isxander.controlify.impl.config.ConfigManager; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.ControllerInfo; +import dev.isxander.controlify.impl.driver.sdl.SDLNativesLoader; +import dev.isxander.controlify.impl.gui.screen.AskToMapControllerScreen; +import dev.isxander.controlify.impl.gui.screen.BluetoothWarningScreen; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.DebugLog; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.impl.utils.SetupWizard; +import dev.isxander.controlify.impl.utils.SetupWizardManager; +import dev.isxander.controlify.impl.utils.event.EventBus; +import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.Nullable; +import static dev.isxander.controlify.impl.utils.ControllerUtils.wrapControllerError; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/// Owns controller discovery, selection, activation and activity subscriptions. +/// Publishes selection changes through {@link ControllerEvents#CURRENT_CONTROLLER_CHANGED}. +public class ControllerCoordinator implements AutoCloseable { + /// Does not initialize a coordinator, backend or any manager when addons subscribe. + public static final class Events { + public static final EventBus DISCOVERY_COMPLETED = new EventBus<>(); + public static final EventBus CONTROLLER_CONNECTED = new EventBus<>(); + public static final EventBus CONTROLLER_DISCONNECTED = new EventBus<>(); + public static final EventBus CURRENT_CONTROLLER_CHANGED = new EventBus<>(); + + private Events() { + } + } + + private final Controlify controlify; + private final ConfigManager config; + private final SetupWizardManager setupWizardManager; + private final ControllerManager controllerManager; + private final Map inputSubscriptions = new HashMap<>(); + private final Set controllersWithInput = new LinkedHashSet<>(); + + private @Nullable ControllerBackend backend; + private @Nullable ControllerImpl currentController; + private boolean hasDiscoveredControllers; + private boolean closed; + + private boolean requireActionPulseConsume; + + public ControllerCoordinator(Controlify controlify, SetupWizardManager setupWizardManager) { + this.controlify = controlify; + this.config = controlify.config(); + this.setupWizardManager = setupWizardManager; + + this.controllerManager = new ControllerManager(this::onControllerAdded, this::onControllerRemoved); + } + + public boolean initialize() { + try { + this.backend = new SDLControllerBackend(SDLNativesLoader.load(), this.controlify, this.controllerManager); + this.backend.setup(); + } catch (Throwable throwable) { + CUtil.LOGGER.error("Failed to initialize controller backend", throwable); + this.backend = null; + return false; + } + return true; + } + + public boolean isReady() { + return this.backend != null && !this.closed; + } + + public ControllerManager controllerManager() { + return this.controllerManager; + } + + public Optional currentController() { + return Optional.ofNullable(this.currentController); + } + + public void discoverControllers() { + if (!this.isReady()) { + return; + } + if (this.hasDiscoveredControllers) { + CUtil.LOGGER.warn("Attempted to discover controllers twice!"); + return; + } + this.hasDiscoveredControllers = true; + DebugLog.log("Discovering and initializing controllers..."); + this.backend.discoverControllers(); + if (this.controllerManager.getAllControllers().isEmpty()) { + CUtil.LOGGER.log("No controllers found."); + } + this.applyControllerSelection(false); + this.config.saveIfDirty(); + + Events.DISCOVERY_COMPLETED.emit(this.controlify); + } + + /// Polls every controller and resolves selection after all events have been received. + /// @return whether the selected controller received non-sensor input during this poll + public boolean update(boolean outOfFocus) { + this.controllersWithInput.clear(); + + if (!this.isReady()) { + return false; + } + + var contexts = outOfFocus + ? List.of() + : ActionLayerRegistryImpl.INSTANCE.getActiveContexts(Minecraft.getInstance()); + for (var controller : this.controllerManager.getAllControllers()) { + controller.input().actionDispatcher().setCurrentContexts(contexts); + } + + this.backend.pollEvents(); + + for (var controller : this.controllerManager.getAllControllers()) { + controller.update(); + } + + if (!outOfFocus && this.config.getActiveProfile().controllerUid == null + && !this.controllersWithInput.contains(this.currentController)) { + // The active controller wins regardless of the order of events within the poll. + this.controllersWithInput.stream().findFirst().ifPresent(controller -> wrapControllerError( + () -> this.setCurrentController(controller, true), + "Selecting controller from input", controller)); + } + return !outOfFocus && this.controllersWithInput.contains(this.currentController); + } + + public void fixedUpdate() { + this.requireActionPulseConsume = true; + } + + public void postUpdate() { + // after a FixedUpdate + if (this.requireActionPulseConsume) { + this.requireActionPulseConsume = false; + + for (var controller : this.controllerManager.getAllControllers()) { + for (var action : controller.input().getAllActions()) { + action.state().consumeAllPulses(); + } + } + } + } + + public void setCurrentController(@Nullable ControllerImpl controller, boolean changeInputMode) { + if (this.closed || this.currentController == controller) { + return; + } + if (this.currentController != null) { + this.currentController.setActive(false); + } + this.currentController = controller; + if (controller != null) { + controller.setActive(true); + DebugLog.log("Updated current controller to {}({})", controller.name(), controller.uid()); + } else { + DebugLog.log("Cleared current controller."); + } + this.controlify.onCurrentControllerChanged(controller, changeInputMode); + Events.CURRENT_CONTROLLER_CHANGED.emit(new ControllerEvents.CurrentControllerChanged(this.controlify, controller)); + } + + public void applyControllerSelection(boolean changeInputMode) { + String selectedUid = this.config.getActiveProfile().controllerUid; + ControllerImpl selected = this.controllerManager.getAllControllers().stream() + .filter(controller -> selectedUid == null || selectedUid.equals(controller.uid())) + .findFirst().orElse(null); + this.setCurrentController(selected, changeInputMode); + } + + /// Recreates type-dependent drivers and components through the normal remove/add lifecycle. + public void reinitController(ControllerImpl controller, ControllerType newType) { + if (!this.isReady()) { + return; + } + var info = controller.info(); + if (this.controllerManager.getControllerByUcid(info.ucid()).orElse(null) != controller) { + throw new IllegalArgumentException("Controller is not registered with this coordinator"); + } + ControllerImpl previousSelection = this.currentController; + InputMode previousInputMode = this.controlify.currentInputMode(); + this.controllerManager.removeController(info.ucid()); + try { + this.backend.createAndAddController(new ControllerInfo(info.ucid(), newType, info.hid()), false); + } finally { + // Adding an auto-selected controller must not steal selection from another device. + if (previousSelection == controller) { + this.controllerManager.getControllerByUcid(info.ucid()) + .ifPresent(replacement -> this.setCurrentController(replacement, false)); + } else { + this.setCurrentController(previousSelection, false); + } + this.controlify.setInputMode(previousInputMode); + } + } + + public void applyActiveProfile() { + this.controllerManager.getAllControllers().forEach(controller -> + controller.setSettings(this.config.getActiveProfile())); + this.applyControllerSelection(true); + } + + private void onControllerAdded(ControllerImpl controller, boolean hotplugged) { + if (this.closed) { + return; + } + EventSource.Subscription previous = this.inputSubscriptions.remove(controller); + if (previous != null) { + previous.close(); + } + this.inputSubscriptions.put(controller, controller.input().inputPipeline().sourceProcessedPhysicalSignals() + .filter(signal -> signal instanceof PhysicalInputSignal) + .subscribe(_ -> this.controllersWithInput.add(controller))); + + DeviceSettings rememberedDevice = this.config.getSettings().getOrCreateDeviceSettings(controller.uid()); + rememberedDevice.name = controller.name(); + rememberedDevice.lastSeen = System.currentTimeMillis(); + rememberedDevice.controllerType = controller.info().type().id(); + this.config.markDirty(); + + String selectedUid = this.config.getActiveProfile().controllerUid; + if (selectedUid == null || selectedUid.equals(controller.uid())) { + this.setCurrentController(controller, true); + } + + SetupWizard wizard = new SetupWizard(); + wizard.addStage( + () -> !controller.input().isGamepad() + && this.config.getSettings().getOrCreateDeviceSettings(controller.uid()).mapping == null, + nextScreen -> new AskToMapControllerScreen(controller, nextScreen)); + wizard.addStage( + () -> controller.dualsense().isPresent() + && controller.bluetoothDevice().map(bt -> !bt.settings().dontShowWarning).orElse(false), + nextScreen -> new BluetoothWarningScreen(controller.bluetoothDevice().orElseThrow(), nextScreen)); + if (hotplugged) { + MinecraftUtil.sendToast( + Component.translatable("controlify.toast.controller_connected.title"), + Component.translatable("controlify.toast.controller_connected.description", controller.name()), false); + this.config.saveIfDirty(); + } + this.setupWizardManager.push(wizard); + Events.CONTROLLER_CONNECTED.emit(new ControllerEvents.ControllerConnected(this.controlify, controller, hotplugged)); + } + + private void onControllerRemoved(ControllerImpl controller) { + if (this.closed) { + return; + } + EventSource.Subscription subscription = this.inputSubscriptions.remove(controller); + if (subscription != null) { + subscription.close(); + } + this.controllersWithInput.remove(controller); + if (this.currentController == controller) { + ControllerImpl fallback = this.config.getActiveProfile().controllerUid == null + ? this.controllerManager.getAllControllers().stream() + .filter(candidate -> candidate != controller).findFirst().orElse(null) + : null; + this.setCurrentController(fallback, true); + } + MinecraftUtil.sendToast( + Component.translatable("controlify.toast.controller_disconnected.title"), + Component.translatable("controlify.toast.controller_disconnected.description", controller.name()), false); + Events.CONTROLLER_DISCONNECTED.emit(new ControllerEvents.ControllerDisconnected(this.controlify, controller)); + } + + @Override + public void close() { + if (this.closed) { + return; + } + this.closed = true; + this.inputSubscriptions.values().forEach(EventSource.Subscription::close); + this.inputSubscriptions.clear(); + this.controllersWithInput.clear(); + this.currentController = null; + this.controllerManager.close(); + } + + public @Nullable ControllerBackend backend() { + return this.backend; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerManager.java b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerManager.java new file mode 100644 index 000000000..d74ada3ec --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerManager.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controllermanager; + +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.driver.steamdeck.SteamDeckUtil; +import dev.isxander.controlify.impl.hid.HIDID; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.ControllerUtils; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; + +import java.io.Closeable; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public class ControllerManager implements Closeable { + private final Map controllersByUid; + private final Map controllersByUcid; + + private final BiConsumer onAdded; + private final Consumer onRemoved; + + public ControllerManager(BiConsumer onAdded, Consumer onRemoved) { + this.onAdded = onAdded; + this.onRemoved = onRemoved; + this.controllersByUid = new HashMap<>(); + this.controllersByUcid = new HashMap<>(); + } + + public boolean canAddController( + UniqueControllerID ucid, + HIDID hidid, + ControllerType controllerType, + ControlifyLogger controllerLogger + ) { + if (this.controllersByUcid.containsKey(ucid)) { + controllerLogger.warn("Tried to create controller that already is initialised: {}.", ucid); + return false; + } + + if (controllerType.preventLoading()) { + controllerLogger.debugLog("Preventing load of controller #" + ucid + " because its type prevents loading."); + return false; + } + + if (controllerType.isSteamDeck() + && SteamDeckUtil.DECK_MODE.isDesktopMode() + ) { + controllerLogger.log("Preventing load of controller #{} because Steam Deck is in desktop mode.", ucid); + return false; + } + + return true; + } + + public void addController(ControllerImpl controller, boolean hotplugged) { + this.controllersByUcid.put(controller.info().ucid(), controller); + this.controllersByUid.put(controller.uid(), controller); + + CUtil.LOGGER.log("Controller connected: {}", ControllerUtils.createControllerString(controller)); + + ControllerUtils.wrapControllerError( + () -> this.onAdded.accept(controller, hotplugged), + "Connecting controller", + controller + ); + } + + public void removeController(UniqueControllerID controllerUcid) { + ControllerImpl controller = this.controllersByUcid.get(controllerUcid); + + if (controller == null) { + throw new IllegalArgumentException("The given ucid is not associated with any connected controller"); + } + + this.controllersByUcid.remove(controllerUcid, controller); + this.controllersByUid.remove(controller.uid(), controller); + + try { + this.onRemoved.accept(controller); + } finally { + controller.close(); + } + } + + public Optional getControllerByUcid(UniqueControllerID ucid) { + return Optional.ofNullable(this.controllersByUcid.get(ucid)); + } + + public Optional getControllerByUid(String uid) { + return Optional.ofNullable(this.controllersByUid.get(uid)); + } + + public Collection getAllControllers() { + return Collections.unmodifiableCollection(this.controllersByUcid.values()); + } + + @Override + public void close() { + this.controllersByUcid.values().forEach(ControllerImpl::close); + this.controllersByUcid.clear(); + this.controllersByUid.clear(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerType.java b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerType.java new file mode 100644 index 000000000..eaf101050 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerType.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controllermanager; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import dev.isxander.controlify.impl.driver.steamdeck.SteamDeckUtil; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; + +import java.util.Optional; + +public record ControllerType( + Identifier id, + Optional friendlyName, + Optional mappingId, + boolean forceJoystick, + boolean preventLoading +) { + public static final ControllerType DEFAULT = new ControllerType(CUtil.rl("default"), Optional.empty(), Optional.empty(), false, false); + + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> instance.group( + Identifier.CODEC.optionalFieldOf("id", DEFAULT.id()).forGetter(ControllerType::id), + Codec.STRING.optionalFieldOf("friendly_name").forGetter(ControllerType::friendlyName), + Identifier.CODEC.optionalFieldOf("mapping_id").forGetter(ControllerType::mappingId), + Codec.BOOL.optionalFieldOf("force_joystick", false).forGetter(ControllerType::forceJoystick), + Codec.BOOL.optionalFieldOf("prevent_loading", false).forGetter(ControllerType::preventLoading) + ).apply(instance, ControllerType::new)); + + public Identifier getIconSprite() { + return id.withPrefix("controllers/"); + } + + public boolean isSteamDeck() { + return id.equals(SteamDeckUtil.STEAM_DECK_NAMESPACE); + } +} diff --git a/src/main/java/dev/isxander/controlify/controller/id/ControllerTypeManager.java b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerTypeManager.java similarity index 69% rename from src/main/java/dev/isxander/controlify/controller/id/ControllerTypeManager.java rename to src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerTypeManager.java index 458afcf3f..768ebb208 100644 --- a/src/main/java/dev/isxander/controlify/controller/id/ControllerTypeManager.java +++ b/src/main/java/dev/isxander/controlify/impl/controllermanager/ControllerTypeManager.java @@ -4,22 +4,19 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.id; +package dev.isxander.controlify.impl.controllermanager; import com.google.gson.JsonElement; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; import com.mojang.serialization.JsonOps; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controllermanager.ControllerManager; -import dev.isxander.controlify.hid.ControllerHIDInfo; -import dev.isxander.controlify.hid.HIDDevice; -import dev.isxander.controlify.hid.HIDID; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.JsonTreeParser; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.hid.HIDID; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.JsonTreeParser; import net.minecraft.util.Util; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.Resource; @@ -56,7 +53,7 @@ public Map getTypeMap() { @Override public CompletableFuture load(ResourceManager manager, Executor executor) { - return CompletableFuture.supplyAsync(() -> manager.getResourceStack(CUtil.rl("controllers/controller_identification.json5")), executor) + return CompletableFuture.supplyAsync(() -> manager.getResourceStack(CUtil.rl("controller/controller_identification.json5")), executor) .thenCompose(resources -> { List>>> futures = new ArrayList<>(); for (Resource resource : resources) { @@ -104,34 +101,18 @@ public CompletableFuture apply(Preparations data, ResourceManager manager, } public void triggerFullTypeReload() { - Optional controllerManagerOpt = Controlify.instance().getControllerManager(); - if (controllerManagerOpt.isPresent()) { - ControllerManager controllerManager = controllerManagerOpt.get(); - - for (ControllerEntity controller : controllerManager.getConnectedControllers()) { - reloadTypeForController(controllerManager, controller); + Controlify.instance().getControllerCoordinator().ifPresent(coordinator -> { + // Reinitialization replaces entries in the manager's live collection. + for (ControllerImpl controller : List.copyOf(coordinator.controllerManager().getAllControllers())) { + reloadTypeForController(coordinator, controller); } - } + }); } - public void reloadTypeForController(ControllerManager controllerManager, ControllerEntity controller) { - Optional hidOpt = controller.info().hid(); - if (hidOpt.isEmpty()) return; - - HIDDevice hid = hidOpt.get(); - - ControllerType newType = this.getControllerType(hid.hidid()); - ControllerType oldType = controller.info().type(); - - // re-initialise the controller if its type has changed - if (!newType.equals(oldType)) { - controllerManager.reinitController( - controller, - new ControllerHIDInfo( - newType, - controller.info().hid() - ) - ); + public void reloadTypeForController(ControllerCoordinator coordinator, ControllerImpl controller) { + ControllerType newType = this.getControllerType(controller.info().hid()); + if (!newType.equals(controller.info().type())) { + coordinator.reinitController(controller, newType); } } diff --git a/src/main/java/dev/isxander/controlify/impl/controllermanager/SDLControllerBackend.java b/src/main/java/dev/isxander/controlify/impl/controllermanager/SDLControllerBackend.java new file mode 100644 index 000000000..7c9401dd6 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controllermanager/SDLControllerBackend.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controllermanager; + +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.ControllerInfo; +import dev.isxander.controlify.impl.debug.DebugProperties; +import dev.isxander.controlify.impl.driver.Driver; +import dev.isxander.controlify.impl.driver.sdl.*; +import dev.isxander.controlify.impl.hid.HIDID; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.event.EventBus; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; +import dev.isxander.sdl.*; +import net.minecraft.client.Minecraft; +import net.minecraft.server.packs.resources.Resource; + +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.Optional; + +public class SDLControllerBackend extends ControllerBackend { + private final Sdl sdl; + private final ControlifyLogger logger; + private final SDLControllerEventStage controllerEventStage; + private final EventBus controllerEventBus; + + private SdlEvent event; + private long lastFrameTimeNanos = -1; + + public SDLControllerBackend(Sdl sdl, Controlify controlify, ControllerManager controllerManager) { + super(controlify, controllerManager); + this.sdl = sdl; + this.logger = CUtil.LOGGER.createSubLogger("SDLControllerBackend"); + this.controllerEventStage = new SDLControllerEventStage(); + this.controllerEventBus = new EventBus<>(); + this.event = new SdlEvent(); + } + + @Override + public void setup() { + this.logger.debugLog("Loading gamepad mappings..."); + + Optional resourceOpt = Minecraft.getInstance().getResourceManager() + .getResource(CUtil.rl("controller/gamecontrollerdb-sdl3.txt")); + if (resourceOpt.isEmpty()) { + this.logger.error("Failed to find game controller database."); + return; + } + + try (InputStream is = resourceOpt.get().open()) { + byte[] bytes = is.readAllBytes(); + ByteBuffer byteBuffer = ByteBuffer.allocateDirect(bytes.length); + byteBuffer.put(bytes); + byteBuffer.flip(); + + SdlIoStreamHandle stream = null; + try { + stream = this.sdl.ioStream().SDL_IOFromConstMem(byteBuffer); + if (stream.isNull()) { + throw new IllegalStateException("Failed to open stream"); + } + + int count = this.sdl.gamepad().SDL_AddGamepadMappingsFromIO(stream, true); + if (count < 0) { + this.logger.error("Failed to load gamepad mappings: {}", this.sdl.error().SDL_GetError()); + } else if (count == 0) { + this.logger.warn("Successfully applied gamepad mappings but none were applied. Unsupported OS?"); + } else { + this.logger.log("Successfully loaded {} gamepad mapping entries!", count); + } + } finally { + if (stream != null) { + // I think SDL_AddGamepadMappingsFromIO closes this stream + // as it JVM crashes if I call this + //this.sdl.ioStream().SDL_CloseIO(stream); + } + } + } catch (Throwable e) { + this.logger.error("Failed to load gamepad mappings", e); + } + } + + @Override + public void discoverControllers() { + this.logger.debugLog("Discovering controllers..."); + + SdlJoystickId[] joysticks = this.sdl.joystick().SDL_GetJoysticks(); + for (SdlJoystickId jid : joysticks) { + this.tryCreateAndAddController(jid, false); + } + } + + @Override + public void pollEvents() { + if (this.event == null) { + this.logger.warn("SDL_Event has somehow been set to null. Recreating..."); + this.event = new SdlEvent(); + } + + while (this.sdl.events().SDL_PollEvent(this.event)) { + this.controllerEventStage.onEvent(this.event, this::onControllerEvent); + } + + long timeNanos = this.sdl.timer().SDL_GetTicksNS(); + long deltaTimeNanos = this.lastFrameTimeNanos == -1 ? 0 : timeNanos - this.lastFrameTimeNanos; + this.lastFrameTimeNanos = timeNanos; + this.onControllerEvent(new SDLControllerEvent.Frame(timeNanos, deltaTimeNanos)); + } + + private void onControllerEvent(SDLControllerEvent event) { + this.controllerEventBus.emit(event); + + switch (event) { + case SDLControllerEvent.JoyDevice(SdlJoystickId which, _, SDLControllerEvent.DeviceEvent.Type eventType) + when eventType == SDLControllerEvent.DeviceEvent.Type.ADDED -> { + this.tryCreateAndAddController(which, true); + } + + case SDLControllerEvent.JoyDevice(SdlJoystickId which, _, SDLControllerEvent.DeviceEvent.Type type) + when type == SDLControllerEvent.DeviceEvent.Type.REMOVED -> { + this.controllerManager.getControllerByUcid(new SDLUniqueControllerID(which)) + .ifPresent(controller -> this.controllerManager.removeController(controller.info().ucid())); + } + + default -> {} + } + } + + private void tryCreateAndAddController(SdlJoystickId jid, boolean hotplugged) { + HIDID hidId = this.getJoystickHIDID(jid); + ControllerType type = this.getControllerType(hidId); + this.createAndAddController(new ControllerInfo(new SDLUniqueControllerID(jid), type, hidId), hotplugged); + } + + @Override + public void createAndAddController(ControllerInfo info, boolean hotplugged) { + if (!(info.ucid() instanceof SDLUniqueControllerID ucid)) { + throw new IllegalArgumentException("SDL backend requires an SDL controller ID"); + } + var logger = this.logger.createSubLogger("Controller #" + ucid); + if (this.controllerManager.canAddController(ucid, info.hid(), info.type(), logger)) { + this.createAndAddController(ucid, info.hid(), info.type(), logger, hotplugged); + } + } + + private void createAndAddController( + SDLUniqueControllerID ucid, + HIDID hidId, + ControllerType type, + ControlifyLogger controllerLogger, + boolean hotplugged + ) { + SdlJoystickId jid = ucid.jid(); + controllerLogger.debugLog("Creating controller: {}", jid.value()); + + boolean isGamepad = this.sdl.gamepad().SDL_IsGamepad(jid) && !DebugProperties.FORCE_JOYSTICK && !type.forceJoystick(); + controllerLogger.debugLog("Controller is gamepad: {}", isGamepad); + + EventSource controllerEventSource = this.controllerEventBus + .filter(event -> event instanceof SDLControllerEvent.Frame + || event instanceof SDLControllerEvent.DeviceScopedEvent scoped && scoped.which().equals(jid)); + + Driver driver; + if (isGamepad) { + SdlGamepadHandle gamepadHandle = SDLUtil.openGamepad(this.sdl, jid); + driver = new SDL3GamepadDriver(this.sdl, gamepadHandle, controllerEventSource, jid, type, controllerLogger); + } else { + SdlJoystickHandle joystickHandle = SDLUtil.openJoystick(this.sdl, jid); + driver = new SDL3JoystickDriver(this.sdl, joystickHandle, controllerEventSource, jid, type, controllerLogger); + } + + var controllerInfo = new ControllerInfo(ucid, type, hidId); + var controller = new ControllerImpl( + controllerInfo, + driver, + controllerLogger, + Controlify.instance().config().getActiveProfile(), + ProfileSettings.createDefault() + ); + + this.controllerManager.addController(controller, hotplugged); + } + + private HIDID getJoystickHIDID(SdlJoystickId jid) { + int vid = this.sdl.joystick().SDL_GetJoystickVendorForID(jid); + int pid = this.sdl.joystick().SDL_GetJoystickProductForID(jid); + + return new HIDID(vid, pid); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/controllermanager/SDLUniqueControllerID.java b/src/main/java/dev/isxander/controlify/impl/controllermanager/SDLUniqueControllerID.java new file mode 100644 index 000000000..317e7e0a6 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/controllermanager/SDLUniqueControllerID.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.controllermanager; + +import dev.isxander.sdl.SdlJoystickId; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public record SDLUniqueControllerID(@NotNull SdlJoystickId jid) implements UniqueControllerID { + @Override + public boolean equals(Object obj) { + return obj instanceof SDLUniqueControllerID && ((SDLUniqueControllerID) obj).jid.equals(jid); + } + + @Override + public String toString() { + return "SDL-" + jid.value(); + } + + @Override + public int hashCode() { + return Objects.hash(jid.value()); + } +} diff --git a/src/main/java/dev/isxander/controlify/controllermanager/UniqueControllerID.java b/src/main/java/dev/isxander/controlify/impl/controllermanager/UniqueControllerID.java similarity index 88% rename from src/main/java/dev/isxander/controlify/controllermanager/UniqueControllerID.java rename to src/main/java/dev/isxander/controlify/impl/controllermanager/UniqueControllerID.java index 99886cb70..05284e8fd 100644 --- a/src/main/java/dev/isxander/controlify/controllermanager/UniqueControllerID.java +++ b/src/main/java/dev/isxander/controlify/impl/controllermanager/UniqueControllerID.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controllermanager; +package dev.isxander.controlify.impl.controllermanager; /** * A unique, abstract identifier for a controller. diff --git a/src/main/java/dev/isxander/controlify/debug/DebugProperties.java b/src/main/java/dev/isxander/controlify/impl/debug/DebugProperties.java similarity index 95% rename from src/main/java/dev/isxander/controlify/debug/DebugProperties.java rename to src/main/java/dev/isxander/controlify/impl/debug/DebugProperties.java index b33753310..87209d97f 100644 --- a/src/main/java/dev/isxander/controlify/debug/DebugProperties.java +++ b/src/main/java/dev/isxander/controlify/impl/debug/DebugProperties.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.debug; +package dev.isxander.controlify.impl.debug; -import dev.isxander.controlify.platform.main.PlatformMainUtil; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.utils.CUtil; import org.jetbrains.annotations.Nullable; import java.util.ArrayList; diff --git a/src/main/java/dev/isxander/controlify/driver/ComponentAdderDriver.java b/src/main/java/dev/isxander/controlify/impl/driver/ComponentAdderDriver.java similarity index 51% rename from src/main/java/dev/isxander/controlify/driver/ComponentAdderDriver.java rename to src/main/java/dev/isxander/controlify/impl/driver/ComponentAdderDriver.java index b1a87c928..49fb63d81 100644 --- a/src/main/java/dev/isxander/controlify/driver/ComponentAdderDriver.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/ComponentAdderDriver.java @@ -4,21 +4,21 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver; +package dev.isxander.controlify.impl.driver; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.impl.controller.ControllerImpl; import java.util.function.Consumer; public class ComponentAdderDriver implements Driver { - private final Consumer componentAdder; + private final Consumer componentAdder; - public ComponentAdderDriver(Consumer componentAdder) { + public ComponentAdderDriver(Consumer componentAdder) { this.componentAdder = componentAdder; } @Override - public void addComponents(ControllerEntity controller) { + public void addComponents(ControllerImpl controller) { componentAdder.accept(controller); } } diff --git a/src/main/java/dev/isxander/controlify/driver/CompoundDriver.java b/src/main/java/dev/isxander/controlify/impl/driver/CompoundDriver.java similarity index 69% rename from src/main/java/dev/isxander/controlify/driver/CompoundDriver.java rename to src/main/java/dev/isxander/controlify/impl/driver/CompoundDriver.java index fa157e05f..a3d6160d1 100644 --- a/src/main/java/dev/isxander/controlify/driver/CompoundDriver.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/CompoundDriver.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver; +package dev.isxander.controlify.impl.driver; import com.google.common.collect.Lists; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.impl.controller.ControllerImpl; import java.util.List; import java.util.stream.Collectors; @@ -20,16 +20,23 @@ public CompoundDriver(List drivers) { } @Override - public void addComponents(ControllerEntity controller) { + public void addComponents(ControllerImpl controller) { for (Driver driver : Lists.reverse(drivers)) { driver.addComponents(controller); } } @Override - public void update(ControllerEntity controller, boolean outOfFocus) { + public void update(ControllerImpl controller) { for (Driver driver : drivers) { - driver.update(controller, outOfFocus); + driver.update(controller); + } + } + + @Override + public void setActive(boolean active) { + for (Driver driver : drivers) { + driver.setActive(active); } } diff --git a/src/main/java/dev/isxander/controlify/impl/driver/Driver.java b/src/main/java/dev/isxander/controlify/impl/driver/Driver.java new file mode 100644 index 000000000..a58727298 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/driver/Driver.java @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.driver; + +import dev.isxander.controlify.impl.controller.ControllerImpl; + +public interface Driver { + default void addComponents(ControllerImpl controller) {} + + default void update(ControllerImpl controller) {} + + /// Enables resources needed only by the active controller, such as sensor sampling. + /// Buttons and axes must remain available while inactive for controller selection. + default void setActive(boolean active) {} + + default void close() {} +} diff --git a/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseEffectsState.java b/src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseEffectsState.java similarity index 95% rename from src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseEffectsState.java rename to src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseEffectsState.java index 48d02c485..00e4251f2 100644 --- a/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseEffectsState.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseEffectsState.java @@ -4,8 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.dualsense; +package dev.isxander.controlify.impl.driver.dualsense; +import dev.isxander.controlify.api.controller.dualsense.MicLightMode; import org.intellij.lang.annotations.MagicConstant; import java.lang.foreign.MemoryLayout; @@ -146,7 +147,7 @@ public final class DualsenseEffectsState { public byte ucAudioEnableBits; - @MagicConstant(valuesFromClass = MuteLightState.class) + @MagicConstant(valuesFromClass = MicLightMode.class) public byte ucMicLightMode; public byte ucAudioMuteBits; @@ -375,7 +376,7 @@ private EnableBitFlags1() { } public static final class EnableBitFlags2 { - public static final byte ALLOW_MUTE_LIGHT = 1; + public static final byte ALLOW_MIC_LIGHT_MODE = 1; public static final byte ALLOW_AUDIO_MUTE = 1 << 1; public static final byte ALLOW_LED_COLOUR = 1 << 2; public static final byte RESET_LIGHTS = 1 << 3; @@ -388,17 +389,26 @@ private EnableBitFlags2() { } } - public static final class MuteLightState { + public static final class MicLightMode { public static final byte OFF = 0; public static final byte ON = 1; public static final byte BREATHING = 2; - @MagicConstant(valuesFromClass = MuteLightState.class) + @MagicConstant(valuesFromClass = MicLightMode.class) public static byte fromBoolean(boolean state) { return state ? ON : OFF; } - private MuteLightState() { + @MagicConstant(valuesFromClass = MicLightMode.class) + public static byte fromEnum(dev.isxander.controlify.api.controller.dualsense.MicLightMode mode) { + return switch (mode) { + case OFF -> OFF; + case ON -> ON; + case BREATHING -> BREATHING; + }; + } + + private MicLightMode() { } } } diff --git a/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffect.java b/src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseTriggerEffect.java similarity index 99% rename from src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffect.java rename to src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseTriggerEffect.java index 33edb3e73..b825d9664 100644 --- a/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffect.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseTriggerEffect.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.dualsense; +package dev.isxander.controlify.impl.driver.dualsense; import org.apache.commons.lang3.Validate; import org.jetbrains.annotations.NotNull; diff --git a/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffectTypes.java b/src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseTriggerEffectTypes.java similarity index 96% rename from src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffectTypes.java rename to src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseTriggerEffectTypes.java index 9fc0680a7..01a77b78e 100644 --- a/src/main/java/dev/isxander/controlify/driver/dualsense/DualsenseTriggerEffectTypes.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/dualsense/DualsenseTriggerEffectTypes.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.dualsense; +package dev.isxander.controlify.impl.driver.dualsense; public final class DualsenseTriggerEffectTypes { diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/DecodedGUID.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/DecodedGUID.java similarity index 98% rename from src/main/java/dev/isxander/controlify/driver/sdl/DecodedGUID.java rename to src/main/java/dev/isxander/controlify/impl/driver/sdl/DecodedGUID.java index 1b11e4ab7..a66b07ce1 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/DecodedGUID.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/DecodedGUID.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl; +package dev.isxander.controlify.impl.driver.sdl; import dev.isxander.sdl.SdlGuid; import org.apache.commons.lang3.Validate; diff --git a/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDL3GamepadDriver.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDL3GamepadDriver.java new file mode 100644 index 000000000..e5c5389ed --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDL3GamepadDriver.java @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.driver.sdl; + +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.controller.touchpad.TouchpadComponent; +import dev.isxander.controlify.impl.controller.gyro.GyroComponentImpl; +import dev.isxander.controlify.impl.controller.touchpad.Touchpads; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; +import dev.isxander.sdl.*; +import net.minecraft.resources.Identifier; + +import java.nio.ByteBuffer; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import static dev.isxander.sdl.SdlGamepad.*; + +public class SDL3GamepadDriver extends SDLCommonDriver { + + private InputComponentImpl inputComponent; + private TouchpadComponent touchpadComponent; + + private final Set supportedButtons; + private final Set supportedAxes; + + private final boolean isGyroSupported; + private boolean active; + + private final int numTouchpads; + + public SDL3GamepadDriver(Sdl sdl, SdlGamepadHandle ptrController, EventSource eventSource, SdlJoystickId jid, ControllerType type, ControlifyLogger logger) { + super(sdl, ptrController, jid, eventSource, type, logger); + + this.supportedButtons = IntStream.range(0, SDL_GAMEPAD_BUTTON_COUNT) + .filter(i -> this.sdl.gamepad().SDL_GamepadHasButton(this.ptrController, i)) + .mapToObj(SDLInputConversions::getGamepadButton) + .collect(Collectors.toSet()); + this.supportedAxes = IntStream.range(0, SDL_GAMEPAD_AXIS_COUNT) + .filter(i -> this.sdl.gamepad().SDL_GamepadHasAxis(this.ptrController, i)) + .boxed() + .flatMap(i -> Stream.of(SDLInputConversions.getGamepadAxis(i, true), SDLInputConversions.getGamepadAxis(i, false))) + .collect(Collectors.toSet()); + this.isGyroSupported = this.sdl.gamepad().SDL_GamepadHasSensor(this.ptrController, SDL_SENSOR_GYRO); + + this.numTouchpads = sdl.gamepad().SDL_GetNumGamepadTouchpads(ptrController); + } + + @Override + public void addComponents(ControllerImpl controller) { + super.addComponents(controller); + + controller.addComponent( + this.inputComponent = new InputComponentImpl( + this.controllerType.id(), + this.inputPipeline, + this.supportedButtons, + this.supportedAxes, + true + ) + ); + + if (this.isGyroSupported) { + controller.addComponent(new GyroComponentImpl(this.inputPipeline)); + } + + if (this.numTouchpads > 0) { + controller.addComponent(this.touchpadComponent = new TouchpadComponent( + new Touchpads( + IntStream.range(0, numTouchpads) + .mapToObj(i -> + new Touchpads.Touchpad( + sdl.gamepad().SDL_GetNumGamepadTouchpadFingers(ptrController, i) + ) + ).toArray(Touchpads.Touchpad[]::new) + ) + )); + } + } + + @Override + public void setActive(boolean active) { + if (this.ptrController == null) { + throw new IllegalStateException("Tried to activate or deactivate a closed controller."); + } + if (this.active == active) { + return; + } + this.active = active; + if (this.isGyroSupported + && !this.sdl.gamepad().SDL_SetGamepadSensorEnabled(this.ptrController, SDL_SENSOR_GYRO, active)) { + this.logger.warn("Failed to {} gyro sensor: {}", active ? "enable" : "disable", this.sdl.error().SDL_GetError()); + } + } + + @Override + protected void onEvent(SDLControllerEvent event) { + // filter out joystick input events if we are a gamepad + if (event instanceof SDLControllerEvent.JoystickEvent && !(event instanceof SDLControllerEvent.DeviceEvent)) { + return; + } + + super.onEvent(event); + + switch (event) { + case SDLControllerEvent.GamepadTouchpad touchpad -> + this.onTouchpadEvent(touchpad); + + default -> {} + } + } + + @Override + public void update(ControllerImpl controller) { + super.update(controller); + } + + @Override + public void close() { + this.setActive(false); + super.close(); + } + + private void onTouchpadEvent(SDLControllerEvent.GamepadTouchpad event) { + // TODO: touchpad component probably needs redoing, it was designed to receive + // complete snapshots of touchpad data, not continuous updates + // TODO: touchpad events should probably be fed down the physical input pipeline + // so we can recognise gestures from finger swipes + } + + @Override + protected SdlPropertiesId SDL_GetControllerProperties(SdlGamepadHandle ptrController) { + return sdl.gamepad().SDL_GetGamepadProperties(ptrController); + } + + @Override + protected String SDL_GetControllerName(SdlGamepadHandle ptrController) { + return sdl.gamepad().SDL_GetGamepadName(ptrController); + } + + @Override + protected SdlGuid SDL_GetControllerGUIDForID(SdlJoystickId jid) { + return sdl.gamepad().SDL_GetGamepadGUIDForID(jid); + } + + @Override + protected String SDL_GetControllerSerial(SdlGamepadHandle ptrController) { + return sdl.gamepad().SDL_GetGamepadSerial(ptrController); + } + + @Override + protected short SDL_GetControllerVendor(SdlGamepadHandle ptrController) { + return sdl.gamepad().SDL_GetGamepadVendor(ptrController); + } + + @Override + protected short SDL_GetControllerProduct(SdlGamepadHandle ptrController) { + return sdl.gamepad().SDL_GetGamepadProduct(ptrController); + } + + @Override + protected int SDL_GetControllerConnectionState(SdlGamepadHandle ptrController) { + return sdl.gamepad().SDL_GetGamepadConnectionState(ptrController); + } + + @Override + protected boolean SDL_CloseController(SdlGamepadHandle ptrController) { + sdl.gamepad().SDL_CloseGamepad(ptrController); + return true; + } + + @Override + protected boolean SDL_RumbleController(SdlGamepadHandle ptrController, float strong, float weak, int durationMs) { + return sdl.gamepad().SDL_RumbleGamepad(ptrController, (short) (strong * 0xFFFF), (short) (weak * 0xFFFF), durationMs); + } + + @Override + protected boolean SDL_RumbleControllerTriggers(SdlGamepadHandle ptrController, float left, float right, int durationMs) { + return sdl.gamepad().SDL_RumbleGamepadTriggers(ptrController, (short) (left * 0xFFFF), (short) (right * 0xFFFF), durationMs); + } + + @Override + protected int SDL_GetControllerPowerInfo(SdlGamepadHandle ptrController, SdlRefs.IntRef percent) { + return sdl.gamepad().SDL_GetGamepadPowerInfo(ptrController, percent); + } + + @Override + protected boolean SDL_SendControllerEffect(SdlGamepadHandle ptrController, ByteBuffer effect) { + return sdl.gamepad().SDL_SendGamepadEffect(ptrController, effect); + } + + @Override + protected boolean SDL_SetControllerLED(SdlGamepadHandle ptrController, byte red, byte green, byte blue) { + return sdl.gamepad().SDL_SetGamepadLED(ptrController, red, green, blue); + } +} diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDL3JoystickDriver.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDL3JoystickDriver.java similarity index 52% rename from src/main/java/dev/isxander/controlify/driver/sdl/SDL3JoystickDriver.java rename to src/main/java/dev/isxander/controlify/impl/driver/sdl/SDL3JoystickDriver.java index c9fad7e39..aa01477d8 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDL3JoystickDriver.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDL3JoystickDriver.java @@ -4,87 +4,66 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl; - -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.controller.input.HatState; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.controller.input.JoystickInputs; -import dev.isxander.controlify.controller.*; -import dev.isxander.controlify.controller.impl.ControllerStateImpl; -import dev.isxander.controlify.utils.log.ControlifyLogger; +package dev.isxander.controlify.impl.driver.sdl; + +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.input.mapping.JoystickInputs; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; import dev.isxander.sdl.*; -import net.minecraft.util.Mth; +import net.minecraft.resources.Identifier; import java.nio.ByteBuffer; import java.util.Set; - -import static dev.isxander.sdl.SdlJoystick.*; +import java.util.Arrays; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; public class SDL3JoystickDriver extends SDLCommonDriver { - private InputComponent inputComponent; - private final int numAxes, numButtons, numHats; + private InputComponentImpl inputComponent; + + private final int numAxes, numButtons; + private final Set supportedAxes; + private final Set supportedButtons; - public SDL3JoystickDriver(Sdl sdl, SdlJoystickHandle ptrJoystick, SdlJoystickId jid, ControllerType type, ControlifyLogger logger) { - super(sdl, ptrJoystick, jid, type, logger); + public SDL3JoystickDriver(Sdl sdl, SdlJoystickHandle ptrJoystick, EventSource eventSource, SdlJoystickId jid, ControllerType type, ControlifyLogger logger) { + super(sdl, ptrJoystick, jid, eventSource, type, logger); this.numAxes = sdl.joystick().SDL_GetNumJoystickAxes(ptrJoystick); this.numButtons = sdl.joystick().SDL_GetNumJoystickButtons(ptrJoystick); - this.numHats = sdl.joystick().SDL_GetNumJoystickHats(ptrJoystick); + + this.supportedAxes = IntStream.range(0, this.numAxes) + .boxed() + .flatMap(i -> Stream.of(JoystickInputs.axis(i, false), JoystickInputs.axis(i, true))) + .collect(Collectors.toSet()); + int numHats = sdl.joystick().SDL_GetNumJoystickHats(ptrJoystick); + this.supportedButtons = Stream.concat( + IntStream.range(0, this.numButtons).mapToObj(JoystickInputs::button), + IntStream.range(0, numHats).boxed().flatMap(hat -> Arrays.stream(JoystickInputs.HatDirection.values()) + .map(direction -> JoystickInputs.hat(hat, direction)))) + .collect(Collectors.toSet()); } @Override - public void addComponents(ControllerEntity controller) { + public void addComponents(ControllerImpl controller) { super.addComponents(controller); - controller.setComponent(this.inputComponent = new InputComponent(controller, numButtons, numAxes * 2, numHats, false, Set.of(), controller.info().type().mappingId())); + controller.addComponent(this.inputComponent = new InputComponentImpl( + this.controllerType.id(), + this.inputPipeline, + this.supportedButtons, + this.supportedAxes, + false + )); } @Override - public void update(ControllerEntity controller, boolean outOfFocus) { - super.update(controller, outOfFocus); - - this.updateInput(); - } - - private void updateInput() { - ControllerStateImpl state = new ControllerStateImpl(); - - for (int i = 0; i < numAxes; i++) { - float axis = mapShortToFloat(sdl.joystick().SDL_GetJoystickAxis(ptrController, i)); - - state.setAxis(JoystickInputs.axis(i, true), Math.max(axis, 0)); - state.setAxis(JoystickInputs.axis(i, false), -Math.min(axis, 0)); - } - - for (int i = 0; i < numButtons; i++) { - state.setButton(JoystickInputs.button(i), sdl.joystick().SDL_GetJoystickButton(ptrController, i)); - } - - for (int i = 0; i < numHats; i++) { - HatState hatState = switch (sdl.joystick().SDL_GetJoystickHat(ptrController, i)) { - case SDL_HAT_CENTERED -> HatState.CENTERED; - case SDL_HAT_UP -> HatState.UP; - case SDL_HAT_RIGHT -> HatState.RIGHT; - case SDL_HAT_DOWN -> HatState.DOWN; - case SDL_HAT_LEFT -> HatState.LEFT; - case SDL_HAT_RIGHTUP -> HatState.RIGHT_UP; - case SDL_HAT_RIGHTDOWN -> HatState.RIGHT_DOWN; - case SDL_HAT_LEFTUP -> HatState.LEFT_UP; - case SDL_HAT_LEFTDOWN -> HatState.LEFT_DOWN; - default -> throw new IllegalStateException("Unexpected value: " + sdl.joystick().SDL_GetJoystickHat(ptrController, i)); - }; - - state.setHat(JoystickInputs.hat(i), hatState); - } - - this.inputComponent.pushState(state); - } - - private static float mapShortToFloat(short value) { - return Mth.clampedMap(value, Short.MIN_VALUE, 0, -1f, 0f) - + Mth.clampedMap(value, 0, Short.MAX_VALUE, 0f, 1f); + public void update(ControllerImpl controller) { + super.update(controller); } @Override diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDLCommonDriver.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLCommonDriver.java similarity index 78% rename from src/main/java/dev/isxander/controlify/driver/sdl/SDLCommonDriver.java rename to src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLCommonDriver.java index 1e75f2c6c..1ff30afd6 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDLCommonDriver.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLCommonDriver.java @@ -4,30 +4,32 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.battery.BatteryLevelComponent; -import dev.isxander.controlify.controller.battery.PowerState; -import dev.isxander.controlify.controller.dualsense.DualsenseComponent; -import dev.isxander.controlify.controller.haptic.CompleteSoundData; -import dev.isxander.controlify.controller.haptic.HDHapticComponent; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.controller.info.DriverNameComponent; -import dev.isxander.controlify.controller.info.GUIDComponent; -import dev.isxander.controlify.controller.info.UIDComponent; -import dev.isxander.controlify.controller.led.LEDComponent; -import dev.isxander.controlify.controller.misc.BluetoothDeviceComponent; -import dev.isxander.controlify.controller.rumble.RumbleComponent; -import dev.isxander.controlify.controller.rumble.TriggerRumbleComponent; -import dev.isxander.controlify.driver.Driver; -import dev.isxander.controlify.driver.dualsense.DualsenseEffectsState; -import dev.isxander.controlify.driver.dualsense.DualsenseTriggerEffect; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.rumble.TriggerRumbleState; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.log.ControlifyLogger; +package dev.isxander.controlify.impl.driver.sdl; + +import dev.isxander.controlify.api.controller.dualsense.MicLightMode; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.battery.BatteryLevelComponent; +import dev.isxander.controlify.impl.controller.battery.PowerState; +import dev.isxander.controlify.impl.controller.dualsense.DualsenseComponentImpl; +import dev.isxander.controlify.impl.controller.haptic.CompleteSoundData; +import dev.isxander.controlify.impl.controller.haptic.HDHapticComponent; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.controller.misc.DriverNameComponent; +import dev.isxander.controlify.impl.controller.misc.UIDComponent; +import dev.isxander.controlify.impl.controller.led.LEDComponent; +import dev.isxander.controlify.impl.controller.misc.BluetoothDeviceComponent; +import dev.isxander.controlify.impl.controller.rumble.RumbleComponentImpl; +import dev.isxander.controlify.impl.controller.rumble.TriggerRumbleComponent; +import dev.isxander.controlify.impl.driver.Driver; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseEffectsState; +import dev.isxander.controlify.impl.driver.dualsense.DualsenseTriggerEffect; +import dev.isxander.controlify.impl.input.pipeline.InputPipeline; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.api.controller.rumble.TriggerRumbleState; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; import dev.isxander.sdl.*; import net.minecraft.util.Util; import org.jetbrains.annotations.NotNull; @@ -43,7 +45,6 @@ import java.util.Optional; import static dev.isxander.sdl.SdlAudio.*; -import static dev.isxander.sdl.SdlGamepad.*; import static dev.isxander.sdl.SdlInit.*; import static dev.isxander.sdl.SdlProperties.*; @@ -51,18 +52,23 @@ public abstract class SDLCommonDriver implements Driver { private static final int AUDIO_STREAM_TIMEOUT_TICKS = 5 * 60 * 60 * 20; // 5 minutes protected final Sdl sdl; - private final ControlifyLogger logger; + protected final ControlifyLogger logger; protected final Arena arena; protected SdlController ptrController; + protected final EventSource sdlEventSource; + protected final InputPipeline inputPipeline; + private final SDLToPhysicalStage sdlToPhysicalStage; + private EventSource.Subscription eventSubscription; + protected BatteryLevelComponent batteryLevelComponent; - protected RumbleComponent rumbleComponent; + protected RumbleComponentImpl rumbleComponent; protected TriggerRumbleComponent triggerRumbleComponent; protected HDHapticComponent hdHapticComponent; protected LEDComponent ledComponent; - protected DualsenseComponent dualSenseComponent; + protected DualsenseComponentImpl dualSenseComponent; protected final boolean isRumbleSupported, isTriggerRumbleSupported; protected final boolean isDualsense; @@ -75,19 +81,22 @@ public abstract class SDLCommonDriver implements Driver { protected final SdlPropertiesId props; protected final short vendorId, productId; protected final SDLJoystickConnectionState connectionState; + protected final ControllerType controllerType; @Nullable protected SdlAudioDeviceId dualsenseAudioDev; @Nullable protected SdlAudioSpec dualsenseAudioSpec; protected final List dualsenseAudioHandles; - public SDLCommonDriver(Sdl sdl, SdlController ptrController, SdlJoystickId jid, ControllerType type, ControlifyLogger logger) { + public SDLCommonDriver(Sdl sdl, SdlController ptrController, SdlJoystickId jid, EventSource eventSource, ControllerType type, ControlifyLogger logger) { this.sdl = sdl; this.arena = Arena.ofConfined(); this.ptrController = ptrController; + this.sdlEventSource = eventSource; this.logger = logger; this.props = SDL_GetControllerProperties(ptrController); + this.controllerType = type; this.name = Optional.ofNullable(SDL_GetControllerName(ptrController)) .orElse("SDL Controller"); @@ -113,10 +122,13 @@ public SDLCommonDriver(Sdl sdl, SdlController ptrController, SdlJoystickId jid, DecodedGUID decodedGuid = DecodedGUID.fromGUID(this.guid); logger.log("SDL GUID driver signature: {}", decodedGuid.getDriverHint()); + this.inputPipeline = new InputPipeline(); + this.sdlToPhysicalStage = new SDLToPhysicalStage(); + // open audio device for dualsense hd haptics this.dualsenseAudioHandles = new ArrayList<>(); - this.isDualsense = CUtil.rl("dualsense").equals(type.namespace()); + this.isDualsense = CUtil.rl("dualsense").equals(type.id()); if (this.isDualsense) { logger.debugLog("DualSense controller detected."); @@ -148,74 +160,70 @@ public SDLCommonDriver(Sdl sdl, SdlController ptrController, SdlJoystickId jid, } @Override - public void addComponents(ControllerEntity controller) { - controller.setComponent(new DriverNameComponent(this.name)); - controller.setComponent(new GUIDComponent(this.guidString)); - controller.setComponent(new UIDComponent(createUid())); + public void addComponents(ControllerImpl controller) { + controller.addComponent(new DriverNameComponent(this.name)); + controller.addComponent(new UIDComponent(createUid())); - controller.setComponent(this.batteryLevelComponent = new BatteryLevelComponent()); + controller.addComponent(this.batteryLevelComponent = new BatteryLevelComponent()); if (this.isRumbleSupported) { - controller.setComponent(this.rumbleComponent = new RumbleComponent()); + controller.addComponent(this.rumbleComponent = new RumbleComponentImpl()); } if (this.isTriggerRumbleSupported) { - controller.setComponent(this.triggerRumbleComponent = new TriggerRumbleComponent()); + controller.addComponent(this.triggerRumbleComponent = new TriggerRumbleComponent()); } if (this.isRGBLedSupported) { - controller.setComponent(this.ledComponent = new LEDComponent(1)); + controller.addComponent(this.ledComponent = new LEDComponent(1)); } if (this.isDualsense) { - controller.setComponent(this.dualSenseComponent = new DualsenseComponent()); + controller.addComponent(this.dualSenseComponent = new DualsenseComponentImpl()); } if (this.dualsenseAudioDev != null) { - controller.setComponent(this.hdHapticComponent = new HDHapticComponent()); + controller.addComponent(this.hdHapticComponent = new HDHapticComponent()); this.hdHapticComponent.acceptPlayHaptic(this::playHaptic); } if (isBluetooth()) { - controller.setComponent(new BluetoothDeviceComponent()); + controller.addComponent(new BluetoothDeviceComponent()); } + + this.eventSubscription = this.sdlEventSource.subscribe(this::onEvent); + } + + protected void onEvent(SDLControllerEvent event) { + this.sdlToPhysicalStage.onEvent(event, this.inputPipeline); + + switch (event) { + case SDLControllerEvent.Battery battery -> + this.onBatteryEvent(battery); + + default -> {} + } + } + + private void onBatteryEvent(SDLControllerEvent.Battery event) { + PowerState level = switch (event.state()) { + case ERROR, UNKNOWN -> new PowerState.Unknown(); + case ON_BATTERY -> new PowerState.Depleting(event.percent()); + case NO_BATTERY -> new PowerState.WiredOnly(); + case CHARGING -> new PowerState.Charging(event.percent()); + case CHARGED -> new PowerState.Full(); + }; + + this.batteryLevelComponent.setBatteryLevel(level); } @Override - public void update(ControllerEntity controller, boolean outOfFocus) { + public void update(ControllerImpl controller) { if (ptrController == null) { throw new IllegalStateException("Tried to update controller when it's closed."); } updateRumble(); - updateBatteryLevel(); updateLED(); updateDualSense(); updateHDHaptic(); } - @Override - public void close() { - if (ptrController == null) { - throw new IllegalStateException("Tried to close controller when it's already closed."); - } - - if (dualSenseComponent != null) { - dualSenseComponent.setLeftTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); - dualSenseComponent.setRightTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); - dualSenseComponent.setMuteLight(false); - updateDualSense(); - } - - SDL_CloseController(ptrController); - ptrController = null; - - if (dualsenseAudioDev != null) { - sdl.audio().SDL_CloseAudioDevice(dualsenseAudioDev); - dualsenseAudioDev = null; - for (AudioStreamHandle handle : dualsenseAudioHandles) { - handle.close(); - } - } - - arena.close(); - } - protected void updateRumble() { if (isRumbleSupported) { Optional stateOpt = this.rumbleComponent.consumeRumble(); @@ -238,22 +246,6 @@ protected void updateRumble() { } } - private void updateBatteryLevel() { - SdlRefs.IntRef percent = new SdlRefs.IntRef(); - int powerState = SDL_GetControllerPowerInfo(ptrController, percent); - - PowerState level = switch (powerState) { - case SDL_POWERSTATE_ERROR, SDL_POWERSTATE_UNKNOWN -> new PowerState.Unknown(); - case SDL_POWERSTATE_ON_BATTERY -> new PowerState.Depleting(percent.value); - case SDL_POWERSTATE_NO_BATTERY -> new PowerState.WiredOnly(); - case SDL_POWERSTATE_CHARGING -> new PowerState.Charging(percent.value); - case SDL_POWERSTATE_CHARGED -> new PowerState.Full(); - default -> throw new IllegalStateException("Unexpected value"); - }; - - this.batteryLevelComponent.setBatteryLevel(level); - } - private void updateLED() { if (ledComponent == null) return; @@ -291,8 +283,8 @@ private void updateDualSense() { }); // Mute Light - effectsState.ucEnableBits2 |= DualsenseEffectsState.EnableBitFlags2.ALLOW_MUTE_LIGHT; - effectsState.ucMicLightMode = DualsenseEffectsState.MuteLightState.fromBoolean(this.dualSenseComponent.getMuteLight()); + effectsState.ucEnableBits2 |= DualsenseEffectsState.EnableBitFlags2.ALLOW_MIC_LIGHT_MODE; + effectsState.ucMicLightMode = DualsenseEffectsState.MicLightMode.fromEnum(this.dualSenseComponent.getMicLightMode()); try (Arena arena = Arena.ofConfined()) { MemorySegment memory = arena.allocate(DualsenseEffectsState.LAYOUT); @@ -314,6 +306,38 @@ private void updateHDHaptic() { } } + @Override + public void close() { + if (ptrController == null) { + throw new IllegalStateException("Tried to close controller when it's already closed."); + } + + if (this.eventSubscription != null) { + this.eventSubscription.close(); + this.eventSubscription = null; + } + + if (dualSenseComponent != null) { + dualSenseComponent.setLeftTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); + dualSenseComponent.setRightTriggerEffect(DualsenseTriggerEffect.Off.INSTANCE); + dualSenseComponent.setMicLightMode(MicLightMode.OFF); + updateDualSense(); + } + + SDL_CloseController(ptrController); + ptrController = null; + + if (dualsenseAudioDev != null) { + sdl.audio().SDL_CloseAudioDevice(dualsenseAudioDev); + dualsenseAudioDev = null; + for (AudioStreamHandle handle : dualsenseAudioHandles) { + handle.close(); + } + } + + arena.close(); + } + private void playHaptic(CompleteSoundData sound) { if (ptrController == null || dualsenseAudioDev == null || dualsenseAudioSpec == null) { return; @@ -403,7 +427,7 @@ protected String createUid() { String nonDuplicateUid = uid; int duplicateCount = (int) Controlify.instance().getControllerManager().orElseThrow() - .getConnectedControllers() + .getAllControllers() .stream() .filter(controller -> controller.uid().startsWith(nonDuplicateUid)) .count(); diff --git a/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLControllerEvent.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLControllerEvent.java new file mode 100644 index 000000000..e562aa88d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLControllerEvent.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.driver.sdl; + +import dev.isxander.sdl.SdlJoystickId; + +public sealed interface SDLControllerEvent { + + long timeNanos(); + + /// Emitted once after each backend poll and delivered to every controller. + /// Uses the same SDL clock as native event timestamps; the first frame has a zero delta. + record Frame(long timeNanos, long deltaTimeNanos) implements SDLControllerEvent {} + + sealed interface DeviceScopedEvent extends SDLControllerEvent { + SdlJoystickId which(); + } + + sealed interface JoystickEvent extends DeviceScopedEvent {} + sealed interface GamepadEvent extends DeviceScopedEvent {} + + sealed interface DeviceEvent extends DeviceScopedEvent { + Type type(); + + enum Type { + ADDED, + REMOVED, + UPDATE_COMPLETE, + /// Gamepad only + REMAPPED, + /// Gamepad only + STEAM_HANDLE_UPDATED, + } + } + + sealed interface AxisEvent extends DeviceScopedEvent { + /// The axis. It may be an index or a constant, depending on the event type. + byte axis(); + + /// The axis value, in the range [-32768, 32767] + short value(); + } + + sealed interface ButtonEvent extends DeviceScopedEvent { + /// The button. It may be an index or a constant, depending on the event type. + byte button(); + + /// True if the button is pressed, false if released. + boolean down(); + } + + /// A joystick device event. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param type the type of device event + record JoyDevice( + SdlJoystickId which, + long timeNanos, + Type type + ) implements JoystickEvent, DeviceEvent {} + + /// A joystick axis motion event. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param axis the axis index + /// @param value the axis value, in the range [-32768, 32767] + record JoyAxis( + SdlJoystickId which, + long timeNanos, + byte axis, + short value + ) implements JoystickEvent, AxisEvent {} + + /// A joystick button event. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param button the button index + /// @param down true if the button is pressed, false if released + record JoyButton( + SdlJoystickId which, + long timeNanos, + byte button, + boolean down + ) implements JoystickEvent, ButtonEvent {} + + /// A raw joystick hat bitmask; converted to directional buttons before entering the input pipeline. + record JoyHat(SdlJoystickId which, long timeNanos, byte hat, byte value) implements JoystickEvent {} + + /// A controller battery level change event. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param state the power state + /// @param percent the battery percentage, in the range [0, 100], or -1 if unknown + record Battery( + SdlJoystickId which, + long timeNanos, + PowerState state, + int percent + ) implements DeviceScopedEvent { + public enum PowerState { + ERROR, + UNKNOWN, + ON_BATTERY, + NO_BATTERY, + CHARGING, + CHARGED, + } + } + + /// A gamepad device event. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param type the type of device event + record GamepadDevice( + SdlJoystickId which, + long timeNanos, + Type type + ) implements GamepadEvent, DeviceEvent {} + + /// A gamepad axis motion event. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param axis the axis + /// @param value the axis value, in the range [-32768, 32767] + record GamepadAxis( + SdlJoystickId which, + long timeNanos, + byte axis, + short value + ) implements GamepadEvent, AxisEvent {} + + /// A gamepad button event. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param button the button + /// @param down true if the button is pressed, false if released + record GamepadButton( + SdlJoystickId which, + long timeNanos, + byte button, + boolean down + ) implements GamepadEvent, ButtonEvent {} + + /// A gamepad touchpad event. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param action the action this event represents + /// @param touchpad the index of the touchpad + /// @param finger the index of the finger on the touchpad + /// @param x normalized in the range [0, 1] with 0 being on the left + /// @param y normalized in the range [0, 1] with 0 being at the top + /// @param pressure normalized in the range [0, 1] + record GamepadTouchpad( + SdlJoystickId which, + long timeNanos, + Action action, + int touchpad, + int finger, + float x, + float y, + float pressure + ) implements GamepadEvent { + public enum Action { + DOWN, + MOTION, + UP, + } + } + + /// A gamepad capsense event. + /// Monitors capacitive touch sensors on controllers. + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param capsense the capsense type + /// @param down true if the capsense is touched + record GamepadCapSense( + SdlJoystickId which, + long timeNanos, + byte capsense, + boolean down + ) implements GamepadEvent {} + + /// A gamepad sensor event + /// @param which the joystick ID + /// @param timeNanos the event timestamp in nanoseconds + /// @param sensor the sensor type + /// @param x the first component of the sensor data + /// @param y the second component of the sensor data, + /// may be 0 if the sensor type is 1-dimensional + /// @param z the third component of the sensor data, + /// may be 0 if the sensor type is not 3-dimensional + /// @param sensorTimestamp the timestamp of the sensor reading in nanoseconds, + /// not necessarily synchronized with the system clock + record GamepadSensor( + SdlJoystickId which, + long timeNanos, + int sensor, + float x, + float y, + float z, + long sensorTimestamp + ) implements GamepadEvent {} +} diff --git a/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLControllerEventStage.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLControllerEventStage.java new file mode 100644 index 000000000..c946e5f71 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLControllerEventStage.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.driver.sdl; + +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.EventStage; +import dev.isxander.sdl.*; +import org.jspecify.annotations.NonNull; + +public class SDLControllerEventStage implements EventStage { + @Override + public void onEvent(@NonNull SdlEvent event, @NonNull EventSink downstream) { + long timeNanos = event.timestamp(); + + switch (event.data()) { + case SdlEvent.JoyAxis(SdlJoystickId which, byte axis, short value) -> + downstream.accept(new SDLControllerEvent.JoyAxis(which, timeNanos, axis, value)); + + case SdlEvent.JoyButton(SdlJoystickId which, byte button, boolean down) -> + downstream.accept(new SDLControllerEvent.JoyButton(which, timeNanos, button, down)); + + case SdlEvent.JoyHat(SdlJoystickId which, byte hat, byte value) -> + downstream.accept(new SDLControllerEvent.JoyHat(which, timeNanos, hat, value)); + + case SdlEvent.JoyDevice(SdlJoystickId which) -> { + var type = SDLInputConversions.getDeviceEventType(event.type()); + if (type != null) { + downstream.accept(new SDLControllerEvent.JoyDevice(which, timeNanos, type)); + } + } + + case SdlEvent.JoyBattery(SdlJoystickId which, int state, int percent) -> { + var stateEnum = SDLInputConversions.getPowerState(state); + if (stateEnum != null) { + downstream.accept(new SDLControllerEvent.Battery(which, timeNanos, stateEnum, percent)); + } + } + + case SdlEvent.GamepadAxis(SdlJoystickId which, byte axis, short value) -> + downstream.accept(new SDLControllerEvent.GamepadAxis(which, timeNanos, axis, value)); + + case SdlEvent.GamepadButton(SdlJoystickId which, byte button, boolean down) -> + downstream.accept(new SDLControllerEvent.GamepadButton(which, timeNanos, button, down)); + + case SdlEvent.GamepadDevice(SdlJoystickId which) -> { + var type = SDLInputConversions.getDeviceEventType(event.type()); + if (type != null) { + downstream.accept(new SDLControllerEvent.GamepadDevice(which, timeNanos, type)); + } + } + + case SdlEvent.GamepadTouchpad(SdlJoystickId which, int touchpad, int finger, float x, float y, float pressure) -> { + var action = SDLInputConversions.getTouchpadAction(event.type()); + if (action != null) { + downstream.accept(new SDLControllerEvent.GamepadTouchpad(which, timeNanos, action, touchpad, finger, x, y, pressure)); + } + } + + case SdlEvent.GamepadSensor(SdlJoystickId which, int sensor, float[] values, long sensorTimestamp) -> { + float x = values.length >= 1 ? values[0] : 0f; + float y = values.length >= 2 ? values[1] : 0f; + float z = values.length >= 3 ? values[2] : 0f; + downstream.accept(new SDLControllerEvent.GamepadSensor(which, timeNanos, sensor, x, y, z, sensorTimestamp)); + } + + default -> {} + } + } + + +} diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDLException.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLException.java similarity index 91% rename from src/main/java/dev/isxander/controlify/driver/sdl/SDLException.java rename to src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLException.java index c416b6c90..06b9b90a8 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDLException.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLException.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl; +package dev.isxander.controlify.impl.driver.sdl; import dev.isxander.sdl.Sdl; diff --git a/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLInputConversions.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLInputConversions.java new file mode 100644 index 000000000..0a92f6145 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLInputConversions.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.driver.sdl; + +import dev.isxander.controlify.impl.input.mapping.GamepadInputs; +import dev.isxander.controlify.impl.input.SensorType; +import dev.isxander.sdl.SdlEvents; +import dev.isxander.sdl.SdlGamepad; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.Nullable; + +import java.util.stream.IntStream; + +import static dev.isxander.sdl.SdlGamepad.*; + +public final class SDLInputConversions { + private static final Identifier[] GAMEPAD_AXES = IntStream.range(0, SDL_GAMEPAD_AXIS_COUNT << 1) + .mapToObj(i -> switch (i) { + case (SDL_GAMEPAD_AXIS_LEFTX << 1) -> GamepadInputs.LEFT_STICK_AXIS_RIGHT; + case (SDL_GAMEPAD_AXIS_LEFTX << 1) | 1 -> GamepadInputs.LEFT_STICK_AXIS_LEFT; + case (SDL_GAMEPAD_AXIS_LEFTY << 1) -> GamepadInputs.LEFT_STICK_AXIS_DOWN; + case (SDL_GAMEPAD_AXIS_LEFTY << 1) | 1 -> GamepadInputs.LEFT_STICK_AXIS_UP; + case (SDL_GAMEPAD_AXIS_RIGHTX << 1) -> GamepadInputs.RIGHT_STICK_AXIS_RIGHT; + case (SDL_GAMEPAD_AXIS_RIGHTX << 1) | 1 -> GamepadInputs.RIGHT_STICK_AXIS_LEFT; + case (SDL_GAMEPAD_AXIS_RIGHTY << 1) -> GamepadInputs.RIGHT_STICK_AXIS_DOWN; + case (SDL_GAMEPAD_AXIS_RIGHTY << 1) | 1 -> GamepadInputs.RIGHT_STICK_AXIS_UP; + case (SDL_GAMEPAD_AXIS_LEFT_TRIGGER << 1), + (SDL_GAMEPAD_AXIS_LEFT_TRIGGER << 1) | 1 -> GamepadInputs.LEFT_TRIGGER_AXIS; + case (SDL_GAMEPAD_AXIS_RIGHT_TRIGGER << 1), + (SDL_GAMEPAD_AXIS_RIGHT_TRIGGER << 1) | 1 -> GamepadInputs.RIGHT_TRIGGER_AXIS; + default -> throw new IllegalArgumentException("Unknown axis"); + }) + .toArray(Identifier[]::new); + private static final Identifier[] GAMEPAD_BUTTONS = IntStream.range(0, SDL_GAMEPAD_BUTTON_COUNT) + .mapToObj(i -> switch (i) { + case SDL_GAMEPAD_BUTTON_SOUTH -> GamepadInputs.SOUTH_BUTTON; + case SDL_GAMEPAD_BUTTON_EAST -> GamepadInputs.EAST_BUTTON; + case SDL_GAMEPAD_BUTTON_WEST -> GamepadInputs.WEST_BUTTON; + case SDL_GAMEPAD_BUTTON_NORTH -> GamepadInputs.NORTH_BUTTON; + case SDL_GAMEPAD_BUTTON_BACK -> GamepadInputs.BACK_BUTTON; + case SDL_GAMEPAD_BUTTON_GUIDE -> GamepadInputs.GUIDE_BUTTON; + case SDL_GAMEPAD_BUTTON_START -> GamepadInputs.START_BUTTON; + case SDL_GAMEPAD_BUTTON_LEFT_STICK -> GamepadInputs.LEFT_STICK_BUTTON; + case SDL_GAMEPAD_BUTTON_RIGHT_STICK -> GamepadInputs.RIGHT_STICK_BUTTON; + case SDL_GAMEPAD_BUTTON_LEFT_SHOULDER -> GamepadInputs.LEFT_SHOULDER_BUTTON; + case SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER -> GamepadInputs.RIGHT_SHOULDER_BUTTON; + case SDL_GAMEPAD_BUTTON_DPAD_UP -> GamepadInputs.DPAD_UP_BUTTON; + case SDL_GAMEPAD_BUTTON_DPAD_DOWN -> GamepadInputs.DPAD_DOWN_BUTTON; + case SDL_GAMEPAD_BUTTON_DPAD_LEFT -> GamepadInputs.DPAD_LEFT_BUTTON; + case SDL_GAMEPAD_BUTTON_DPAD_RIGHT -> GamepadInputs.DPAD_RIGHT_BUTTON; + case SDL_GAMEPAD_BUTTON_MISC1 -> GamepadInputs.MISC_1_BUTTON; + case SDL_GAMEPAD_BUTTON_RIGHT_PADDLE1 -> GamepadInputs.RIGHT_PADDLE_1_BUTTON; + case SDL_GAMEPAD_BUTTON_LEFT_PADDLE1 -> GamepadInputs.LEFT_PADDLE_1_BUTTON; + case SDL_GAMEPAD_BUTTON_RIGHT_PADDLE2 -> GamepadInputs.RIGHT_PADDLE_2_BUTTON; + case SDL_GAMEPAD_BUTTON_LEFT_PADDLE2 -> GamepadInputs.LEFT_PADDLE_2_BUTTON; + case SDL_GAMEPAD_BUTTON_TOUCHPAD -> GamepadInputs.TOUCHPAD_1_BUTTON; + case SDL_GAMEPAD_BUTTON_MISC2 -> GamepadInputs.MISC_2_BUTTON; + case SDL_GAMEPAD_BUTTON_MISC3 -> GamepadInputs.MISC_3_BUTTON; + case SDL_GAMEPAD_BUTTON_MISC4 -> GamepadInputs.MISC_4_BUTTON; + case SDL_GAMEPAD_BUTTON_MISC5 -> GamepadInputs.MISC_5_BUTTON; + case SDL_GAMEPAD_BUTTON_MISC6 -> GamepadInputs.MISC_6_BUTTON; + default -> throw new IllegalArgumentException("Unknown button"); + + }) + .toArray(Identifier[]::new); + + private static final SensorType[] GAMEPAD_SENSOR_TYPES = IntStream.range(0, SDL_SENSOR_COUNT) + .mapToObj(i -> switch (i) { + case SDL_SENSOR_GYRO -> SensorType.GYROSCOPE; + case SDL_SENSOR_GYRO_L -> SensorType.GYROSCOPE_L; + case SDL_SENSOR_GYRO_R -> SensorType.GYROSCOPE_R; + case SDL_SENSOR_ACCEL -> SensorType.ACCELEROMETER; + case SDL_SENSOR_ACCEL_L -> SensorType.ACCELEROMETER_L; + case SDL_SENSOR_ACCEL_R -> SensorType.ACCELEROMETER_R; + default -> null; + }) + .toArray(SensorType[]::new); + + public static @Nullable Identifier getGamepadButton(int button) { + if (button < 0 || button >= GAMEPAD_BUTTONS.length) { + return null; + } + return GAMEPAD_BUTTONS[button]; + } + + public static @Nullable Identifier getGamepadAxis(int axis, boolean positive) { + int index = (axis << 1) | (positive ? 0 : 1); + if (index < 0 || index >= GAMEPAD_AXES.length) { + return null; + } + return GAMEPAD_AXES[index]; + } + + public static @Nullable SensorType getSensorType(int sensor) { + if (sensor < 0 || sensor >= GAMEPAD_SENSOR_TYPES.length) { + return null; + } + return GAMEPAD_SENSOR_TYPES[sensor]; + } + + public static SDLControllerEvent.DeviceEvent.@Nullable Type getDeviceEventType(int event) { + return switch (event) { + case SdlEvents.SDL_EVENT_JOYSTICK_ADDED -> SDLControllerEvent.DeviceEvent.Type.ADDED; + case SdlEvents.SDL_EVENT_JOYSTICK_REMOVED -> SDLControllerEvent.DeviceEvent.Type.REMOVED; + case SdlEvents.SDL_EVENT_JOYSTICK_UPDATE_COMPLETE -> SDLControllerEvent.DeviceEvent.Type.UPDATE_COMPLETE; + case SdlEvents.SDL_EVENT_GAMEPAD_REMAPPED -> SDLControllerEvent.DeviceEvent.Type.REMAPPED; + case SdlEvents.SDL_EVENT_GAMEPAD_STEAM_HANDLE_UPDATED -> SDLControllerEvent.DeviceEvent.Type.STEAM_HANDLE_UPDATED; + default -> null; + }; + } + + public static SDLControllerEvent.Battery.@Nullable PowerState getPowerState(int powerState) { + return switch (powerState) { + case SdlGamepad.SDL_POWERSTATE_ERROR -> SDLControllerEvent.Battery.PowerState.ERROR; + case SdlGamepad.SDL_POWERSTATE_UNKNOWN -> SDLControllerEvent.Battery.PowerState.UNKNOWN; + case SdlGamepad.SDL_POWERSTATE_ON_BATTERY -> SDLControllerEvent.Battery.PowerState.ON_BATTERY; + case SdlGamepad.SDL_POWERSTATE_NO_BATTERY -> SDLControllerEvent.Battery.PowerState.NO_BATTERY; + case SdlGamepad.SDL_POWERSTATE_CHARGING -> SDLControllerEvent.Battery.PowerState.CHARGING; + case SdlGamepad.SDL_POWERSTATE_CHARGED -> SDLControllerEvent.Battery.PowerState.CHARGED; + default -> null; + }; + } + + public static SDLControllerEvent.GamepadTouchpad.@Nullable Action getTouchpadAction(int event) { + return switch (event) { + case SdlEvents.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN -> SDLControllerEvent.GamepadTouchpad.Action.DOWN; + case SdlEvents.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION -> SDLControllerEvent.GamepadTouchpad.Action.MOTION; + case SdlEvents.SDL_EVENT_GAMEPAD_TOUCHPAD_UP -> SDLControllerEvent.GamepadTouchpad.Action.UP; + default -> null; + }; + } + + private SDLInputConversions() { + } +} diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDLJoystickConnectionState.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLJoystickConnectionState.java similarity index 92% rename from src/main/java/dev/isxander/controlify/driver/sdl/SDLJoystickConnectionState.java rename to src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLJoystickConnectionState.java index 3b445b171..cb1b2a912 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDLJoystickConnectionState.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLJoystickConnectionState.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl; +package dev.isxander.controlify.impl.driver.sdl; public enum SDLJoystickConnectionState { INVALID(-1), diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDLNativesLoader.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLNativesLoader.java similarity index 95% rename from src/main/java/dev/isxander/controlify/driver/sdl/SDLNativesLoader.java rename to src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLNativesLoader.java index 6524f9184..b2b83498d 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDLNativesLoader.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLNativesLoader.java @@ -4,12 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl; +package dev.isxander.controlify.impl.driver.sdl; -import dev.isxander.controlify.platform.main.PlatformMainUtil; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.Platform; -import dev.isxander.controlify.utils.log.ControlifyLogger; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.Platform; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; import dev.isxander.sdl.Sdl; import dev.isxander.sdl.SdlLoader; import dev.isxander.sdl.SdlVersion; diff --git a/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLToPhysicalStage.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLToPhysicalStage.java new file mode 100644 index 000000000..49c6be141 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLToPhysicalStage.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.driver.sdl; + +import dev.isxander.controlify.impl.input.mapping.JoystickInputs; +import dev.isxander.controlify.impl.input.SensorType; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSensorSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.EventStage; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Arrays; +import java.util.function.Function; + +import static dev.isxander.sdl.SdlJoystick.*; + +public class SDLToPhysicalStage implements EventStage { + private final float[] prevGamepadAxisValues = new float[256]; + private final float[] prevJoystickAxisValues = new float[256]; + private final byte[] prevHatValues = new byte[256]; + + public SDLToPhysicalStage() { + Arrays.fill(this.prevGamepadAxisValues, 0f); + Arrays.fill(this.prevJoystickAxisValues, 0f); + } + + @Override + public void onEvent(@NonNull SDLControllerEvent event, @NonNull EventSink downstream) { + switch (event) { + case SDLControllerEvent.Frame(long timeNanos, long deltaTimeNanos) -> + downstream.accept(new PhysicalSignal.Frame(timeNanos, deltaTimeNanos)); + + case SDLControllerEvent.JoyAxis(_, long timeNanos, byte axis, short value) -> { + int index = Byte.toUnsignedInt(axis); + emitSplitAxis(timeNanos, this.prevJoystickAxisValues, index, value, + positive -> JoystickInputs.axis(index, positive), downstream); + } + + case SDLControllerEvent.JoyButton(_, long timeNanos, byte button, boolean down) -> + downstream.accept(new PhysicalInputSignal.ButtonChanged(timeNanos, JoystickInputs.button(Byte.toUnsignedInt(button)), down)); + + case SDLControllerEvent.JoyHat(_, long timeNanos, byte hat, byte value) -> { + int index = Byte.toUnsignedInt(hat); + byte previous = this.prevHatValues[index]; + this.prevHatValues[index] = value; + if (previous != value) { + int released = previous & ~value; + int pressed = value & ~previous; + var directions = JoystickInputs.HatDirection.values(); + // Release changed directions before pressing new ones; retain shared directions. + for (var direction : directions) { + if ((released & hatMask(direction)) != 0) { + downstream.accept(new PhysicalInputSignal.ButtonChanged( + timeNanos, JoystickInputs.hat(index, direction), false)); + } + } + for (var direction : directions) { + if ((pressed & hatMask(direction)) != 0) { + downstream.accept(new PhysicalInputSignal.ButtonChanged( + timeNanos, JoystickInputs.hat(index, direction), true)); + } + } + } + } + + case SDLControllerEvent.GamepadAxis(_, long timeNanos, byte axis, short value) -> { + int index = Byte.toUnsignedInt(axis); + emitSplitAxis(timeNanos, this.prevGamepadAxisValues, index, value, + positive -> SDLInputConversions.getGamepadAxis(index, positive), downstream); + } + + case SDLControllerEvent.GamepadButton(_, long timeNanos, byte button, boolean down) -> { + Identifier input = SDLInputConversions.getGamepadButton(button); + if (input != null) { + downstream.accept(new PhysicalInputSignal.ButtonChanged(timeNanos, input, down)); + } + } + + case SDLControllerEvent.GamepadSensor(_, long timeNanos, int sensor, float x, float y, float z, long sensorTimestamp) -> { + SensorType sensorType = SDLInputConversions.getSensorType(sensor); + if (sensorType != null) { + downstream.accept(new PhysicalSensorSignal(timeNanos, sensorTimestamp, sensorType, x, y, z)); + } + } + + default -> {} + } + } + + /// Splits a signed axis into two half-axes. + /// When the sign changes, the new half-axis is emitted before the old one is zeroed, + /// so downstream radial deadzones never observe a transiently collapsed magnitude. + private static void emitSplitAxis( + long timeNanos, float[] prevValues, int index, short value, + Function halfAxis, + EventSink downstream + ) { + float normalisedValue = normaliseAxis(value); + + float prevNormalisedValue = prevValues[index]; + prevValues[index] = normalisedValue; + + boolean positive = normalisedValue > 0; + boolean prevPositive = prevNormalisedValue > 0; + + Identifier input = halfAxis.apply(positive); + if (input != null) { + downstream.accept(new PhysicalInputSignal.AxisMoved(timeNanos, input, Math.abs(normalisedValue))); + } + + if (prevPositive != positive) { + Identifier prevInput = halfAxis.apply(prevPositive); + if (prevInput != null && !prevInput.equals(input)) { + downstream.accept(new PhysicalInputSignal.AxisMoved(timeNanos, prevInput, 0f)); + } + } + } + + private static float normaliseAxis(short v) { + return v < 0 ? (v / 32768f) : (v / 32767f); + } + + private static int hatMask(JoystickInputs.HatDirection direction) { + return switch (direction) { + case UP -> SDL_HAT_UP; + case RIGHT -> SDL_HAT_RIGHT; + case DOWN -> SDL_HAT_DOWN; + case LEFT -> SDL_HAT_LEFT; + }; + } +} diff --git a/src/main/java/dev/isxander/controlify/driver/sdl/SDLUtil.java b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLUtil.java similarity index 94% rename from src/main/java/dev/isxander/controlify/driver/sdl/SDLUtil.java rename to src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLUtil.java index 21ddc2a34..29214a77e 100644 --- a/src/main/java/dev/isxander/controlify/driver/sdl/SDLUtil.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/sdl/SDLUtil.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.sdl; +package dev.isxander.controlify.impl.driver.sdl; import dev.isxander.sdl.Sdl; import dev.isxander.sdl.SdlGamepadHandle; diff --git a/src/main/java/dev/isxander/controlify/driver/steamdeck/CEFDebuggerFileResult.java b/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/CEFDebuggerFileResult.java similarity index 93% rename from src/main/java/dev/isxander/controlify/driver/steamdeck/CEFDebuggerFileResult.java rename to src/main/java/dev/isxander/controlify/impl/driver/steamdeck/CEFDebuggerFileResult.java index 960b2ac2f..722e460aa 100644 --- a/src/main/java/dev/isxander/controlify/driver/steamdeck/CEFDebuggerFileResult.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/CEFDebuggerFileResult.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.steamdeck; +package dev.isxander.controlify.impl.driver.steamdeck; public enum CEFDebuggerFileResult { /** The file has just been created, but requires steam to restart to take effect **/ diff --git a/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckDriver.java b/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckDriver.java new file mode 100644 index 000000000..17af69e81 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckDriver.java @@ -0,0 +1,244 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.driver.steamdeck; + +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.battery.BatteryLevelComponent; +import dev.isxander.controlify.impl.controller.battery.PowerState; +import dev.isxander.controlify.impl.controller.gyro.GyroComponentImpl; +import dev.isxander.controlify.impl.controller.misc.DriverNameComponent; +import dev.isxander.controlify.impl.controller.misc.UIDComponent; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.impl.controller.steamdeck.SteamDeckComponent; +import dev.isxander.controlify.impl.controller.touchpad.TouchpadComponent; +import dev.isxander.controlify.impl.controller.touchpad.Touchpads; +import dev.isxander.controlify.impl.driver.Driver; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; +import dev.isxander.deckapi.api.ControllerButton; +import dev.isxander.deckapi.api.ControllerState; +import dev.isxander.deckapi.api.SteamDeck; +import org.joml.Vector2f; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +import static dev.isxander.controlify.impl.utils.CUtil.*; + +public class SteamDeckDriver implements Driver { + private static SteamDeck deck; + private static boolean triedToLoad = false; + + private InputComponentImpl inputComponent; + private GyroComponentImpl gyroComponent; + private BatteryLevelComponent batteryLevelComponent; + private TouchpadComponent touchpadComponent; + + private final ControlifyLogger logger; + + private final AtomicBoolean runningPoller = new AtomicBoolean(false); + + public SteamDeckDriver(ControlifyLogger logger) { + this.logger = logger.createSubLogger("SteamDeckDriver"); + } + + @Override + public void addComponents(ControllerImpl controller) { + controller.addComponent(new DriverNameComponent("Steam Deck")); + controller.addComponent(new UIDComponent(CUtil.createUIDFromBytes("steamdeck".getBytes()))); + +// controller.addComponent( +// this.inputComponent = new InputComponentImpl( +// controller, +// 20, +// 10, +// 0, +// true, +// GamepadInputs.DEADZONE_GROUPS, +// controller.info().type().mappingId() +// ) +// ); + +// controller.addComponent( +// this.gyroComponent = new GyroComponentImpl() +// ); + + // don't add - deck reports incorrect values + this.batteryLevelComponent = new BatteryLevelComponent(); + + controller.addComponent( + this.touchpadComponent = new TouchpadComponent( + new Touchpads( + new Touchpads.Touchpad[]{ + new Touchpads.Touchpad(1), // left + new Touchpads.Touchpad(1), // right + } + ) + ) // there are two touchpads, one for each thumb + ); + + controller.addComponent(new SteamDeckComponent()); + } + + private void ensurePolling() { + if (runningPoller.get()) return; + + logger.debugLog("Spinning up poller thread..."); + runningPoller.set(true); + new Thread(() -> { + int failureScore = 0; + while (runningPoller.get() && failureScore < 50) { + try { + deck.poll() + .orTimeout(1000, java.util.concurrent.TimeUnit.MILLISECONDS) + .join(); // join into thread so we don't spam the CEF before it's returned a value + } catch (Throwable e) { + if (e instanceof CompletionException && e.getCause() instanceof TimeoutException) { + // this might be due to the deck going into sleep mode (suspending) + // we ignore, and wait a bit before trying again to avoid useless cpu usage + logger.debugWarn("Encountered timeout exception while polling deck, assuming deck has suspended and waiting..."); + CUtil.sleepChecked(500); + failureScore += 1; + } else { + logger.error("Encountered exception while polling deck", e); + failureScore += 10; + } + } + } + }, "Steam Deck Poller").start(); + } + + @Override + public void update(ControllerImpl controller) { + ensurePolling(); + + ControllerState deckState = deck.getControllerState(); + + boolean focused = deck.isGameInFocus(); +// +// state.setButton(GamepadInputs.NORTH_BUTTON, deckState.getButtonState(ControllerButton.Y) && focused); +// state.setButton(GamepadInputs.EAST_BUTTON, deckState.getButtonState(ControllerButton.B) && focused); +// state.setButton(GamepadInputs.SOUTH_BUTTON, deckState.getButtonState(ControllerButton.A) && focused); +// state.setButton(GamepadInputs.WEST_BUTTON, deckState.getButtonState(ControllerButton.X) && focused); +// state.setButton(GamepadInputs.LEFT_SHOULDER_BUTTON, deckState.getButtonState(ControllerButton.L1) && focused); +// state.setButton(GamepadInputs.RIGHT_SHOULDER_BUTTON, deckState.getButtonState(ControllerButton.R1) && focused); +// state.setButton(GamepadInputs.START_BUTTON, deckState.getButtonState(ControllerButton.START) && focused); +// state.setButton(GamepadInputs.BACK_BUTTON, deckState.getButtonState(ControllerButton.SELECT) && focused); +// +// // not sure if this should ever be used, since it will always open the steam menu +//// state.setButton(GamepadInputs.GUIDE_BUTTON, deckState.getButtonState(ControllerButton.STEAM_HOME) && focused); +// +// state.setButton(GamepadInputs.LEFT_STICK_BUTTON, deckState.getButtonState(ControllerButton.L3) && focused); +// state.setButton(GamepadInputs.RIGHT_STICK_BUTTON, deckState.getButtonState(ControllerButton.R3) && focused); +// state.setButton(GamepadInputs.DPAD_UP_BUTTON, deckState.getButtonState(ControllerButton.DPAD_UP) && focused); +// state.setButton(GamepadInputs.DPAD_DOWN_BUTTON, deckState.getButtonState(ControllerButton.DPAD_DOWN) && focused); +// state.setButton(GamepadInputs.DPAD_LEFT_BUTTON, deckState.getButtonState(ControllerButton.DPAD_LEFT) && focused); +// state.setButton(GamepadInputs.DPAD_RIGHT_BUTTON, deckState.getButtonState(ControllerButton.DPAD_RIGHT) && focused); +// state.setButton(GamepadInputs.LEFT_PADDLE_1_BUTTON, deckState.getButtonState(ControllerButton.L4) && focused); +// state.setButton(GamepadInputs.LEFT_PADDLE_2_BUTTON, deckState.getButtonState(ControllerButton.L5) && focused); +// state.setButton(GamepadInputs.RIGHT_PADDLE_1_BUTTON, deckState.getButtonState(ControllerButton.R4) && focused); +// state.setButton(GamepadInputs.RIGHT_PADDLE_2_BUTTON, deckState.getButtonState(ControllerButton.R5) && focused); +// state.setButton(GamepadInputs.TOUCHPAD_1_BUTTON, deckState.getButtonState(ControllerButton.LEFT_TOUCHPAD_CLICK) && focused); +// state.setButton(GamepadInputs.TOUCHPAD_2_BUTTON, deckState.getButtonState(ControllerButton.RIGHT_TOUCHPAD_CLICK) && focused); +// state.setAxis(GamepadInputs.LEFT_STICK_AXIS_UP, zeroUnless(positiveAxis(mapShortToFloat(deckState.sLeftStickY())), focused)); +// state.setAxis(GamepadInputs.LEFT_STICK_AXIS_DOWN, zeroUnless(negativeAxis(mapShortToFloat(deckState.sLeftStickY())), focused)); +// state.setAxis(GamepadInputs.LEFT_STICK_AXIS_LEFT, zeroUnless(negativeAxis(mapShortToFloat(deckState.sLeftStickX())), focused)); +// state.setAxis(GamepadInputs.LEFT_STICK_AXIS_RIGHT, zeroUnless(positiveAxis(mapShortToFloat(deckState.sLeftStickX())), focused)); +// state.setAxis(GamepadInputs.RIGHT_STICK_AXIS_UP, zeroUnless(positiveAxis(mapShortToFloat(deckState.sRightStickY())), focused)); +// state.setAxis(GamepadInputs.RIGHT_STICK_AXIS_DOWN, zeroUnless(negativeAxis(mapShortToFloat(deckState.sRightStickY())), focused)); +// state.setAxis(GamepadInputs.RIGHT_STICK_AXIS_LEFT, zeroUnless(negativeAxis(mapShortToFloat(deckState.sRightStickX())), focused)); +// state.setAxis(GamepadInputs.RIGHT_STICK_AXIS_RIGHT, zeroUnless(positiveAxis(mapShortToFloat(deckState.sRightStickX())), focused)); +// state.setAxis(GamepadInputs.LEFT_TRIGGER_AXIS, zeroUnless(mapShortToFloat(deckState.sTriggerL()), focused)); +// state.setAxis(GamepadInputs.RIGHT_TRIGGER_AXIS, zeroUnless(mapShortToFloat(deckState.sTriggerR()), focused)); +// +// this.inputComponent.pushState(state); +// +// this.gyroComponent.setState( +// new GyroState( +// deckState.flSoftwareGyroDegreesPerSecondPitch(), +// -deckState.flSoftwareGyroDegreesPerSecondYaw(), +// -deckState.flSoftwareGyroDegreesPerSecondRoll() +// ).mul(Mth.DEG_TO_RAD) // we need radians per second, not degrees +// ); + + this.batteryLevelComponent.setBatteryLevel( + new PowerState.Depleting((int) (mapShortToFloat(deckState.sBatteryLevel()) * 100)) + ); + + updateTouchpad( + this.touchpadComponent.touchpads()[0], + deckState.sLeftPadX(), + deckState.sLeftPadY(), + deckState.sPressurePadLeft(), + deckState.getButtonState(ControllerButton.LEFT_TOUCHPAD_TOUCH) + ); + updateTouchpad( + this.touchpadComponent.touchpads()[1], + deckState.sRightPadX(), + deckState.sRightPadY(), + deckState.sPressurePadRight(), + deckState.getButtonState(ControllerButton.RIGHT_TOUCHPAD_TOUCH) + ); + } + + private void updateTouchpad(Touchpads.Touchpad touchpad, short x, short y, short pressure, boolean touching) { + if (touching) { + // steam deck touchpad is -32768 to 32767, we need to map it to 0 to 1 + // origin [0,0] is middle and [1,1] is bottom right, we need to map it to top left + float mappedX = (mapShortToFloat(x) + 1) / 2f; + float mappedY = 1 - (mapShortToFloat(y) + 1) / 2f; + // pressure is still 0 up until actuation point (click) + float mappedPressure = mapShortToFloat(pressure); // [0, 1] + + touchpad.pushFingers( + List.of( + new Touchpads.Finger(0, new Vector2f(mappedX, mappedY), mappedPressure) + ) + ); + } else { + touchpad.pushFingers(List.of()); + } + } + + @Override + public void close() { + logger.debugLog("Closing driver..."); + try { + runningPoller.set(false); + deck.close(); + } catch (Exception e) { + logger.error("Failed to close driver", e); + } + } + + public static Optional create(ControlifyLogger logger) { + if (triedToLoad || !Controlify.instance().config().getSettings().globalSettings().useEnhancedSteamDeckDriver) + return Optional.empty(); + + triedToLoad = true; + + try { + deck = SteamDeckUtil.getDeckInstance().orElseThrow(); + return Optional.of(new SteamDeckDriver(logger)); + } catch (Exception e) { + logger.error("Failed to create Steam Deck driver", e); + return Optional.empty(); + } + } + + public static Optional getDeck() { + return Optional.ofNullable(deck); + } + + private static float zeroUnless(float f, boolean condition) { + return condition ? f : 0; + } +} diff --git a/src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckMode.java b/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckMode.java similarity index 87% rename from src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckMode.java rename to src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckMode.java index 9de0fc616..bc671aa5c 100644 --- a/src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckMode.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckMode.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.steamdeck; +package dev.isxander.controlify.impl.driver.steamdeck; public enum SteamDeckMode { GAMING_MODE, diff --git a/src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckUtil.java b/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckUtil.java similarity index 94% rename from src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckUtil.java rename to src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckUtil.java index 010e05184..3c7c65033 100644 --- a/src/main/java/dev/isxander/controlify/driver/steamdeck/SteamDeckUtil.java +++ b/src/main/java/dev/isxander/controlify/impl/driver/steamdeck/SteamDeckUtil.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.driver.steamdeck; +package dev.isxander.controlify.impl.driver.steamdeck; -import dev.isxander.controlify.debug.DebugProperties; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.log.ControlifyLogger; +import dev.isxander.controlify.impl.debug.DebugProperties; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; import dev.isxander.deckapi.api.SteamDeck; import dev.isxander.deckapi.api.SteamDeckException; import net.minecraft.resources.Identifier; diff --git a/src/main/java/dev/isxander/controlify/impl/font/ActionGlyphsImpl.java b/src/main/java/dev/isxander/controlify/impl/font/ActionGlyphsImpl.java new file mode 100644 index 000000000..817a4c28f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/font/ActionGlyphsImpl.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.font; + +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FontDescription; +import net.minecraft.resources.Identifier; + +/// Common-side component encoding shared by the API factories and client mixins. +public final class ActionGlyphsImpl { + public static final Identifier WRAPPER_FONT = Identifier.fromNamespaceAndPath("controlify", "inputs"); + public static final String PLACEHOLDER_KEY = "controlify.placeholder"; + public static final String PLACEHOLDER_CONTROLLER_ACTIVE_KEY = "controlify.placeholder.controller_active"; + + private ActionGlyphsImpl() { + } + + public static Component action(Identifier action) { + return Component.keybind(action.toString()).withStyle(style -> style.withFont(new FontDescription.Resource(WRAPPER_FONT))); + } + + public static Component actionWithFallback(Identifier action, Component fallback) { + return Component.translatableWithFallback(PLACEHOLDER_KEY, "%2$s", action(action), fallback); + } + + public static Component actionWithActiveFallback(Identifier action, Component fallback) { + return Component.translatableWithFallback(PLACEHOLDER_CONTROLLER_ACTIVE_KEY, "%2$s", action(action), fallback); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/font/ComponentFontHelper.java b/src/main/java/dev/isxander/controlify/impl/font/ComponentFontHelper.java new file mode 100644 index 000000000..a392fed00 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/font/ComponentFontHelper.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.font; + +import net.minecraft.client.gui.Font; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.Style; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.util.StringDecomposer; +import org.apache.commons.lang3.mutable.MutableInt; + +public final class ComponentFontHelper { + private ComponentFontHelper() { + } + + public static int getComponentHeight(Font font, FormattedCharSequence text) { + MutableInt mutableInt = new MutableInt(); + text.accept((index, style, codePoint) -> { + mutableInt.setValue(Math.max(mutableInt.intValue(), getHeight(font, codePoint, style))); + return true; + }); + return mutableInt.intValue(); + } + + public static int getComponentHeight(Font font, FormattedText text) { + MutableInt mutableInt = new MutableInt(); + StringDecomposer.iterateFormatted(text, Style.EMPTY, (index, style, codePoint) -> { + mutableInt.setValue(Math.max(mutableInt.intValue(), getHeight(font, codePoint, style))); + return true; + }); + return mutableInt.intValue(); + } + + private static int getHeight(Font font, int codepoint, Style style) { + // i can't figure out how to get the height here + return 15; + } +} diff --git a/src/main/java/dev/isxander/controlify/font/InputFontMapper.java b/src/main/java/dev/isxander/controlify/impl/font/InputFontMapper.java similarity index 52% rename from src/main/java/dev/isxander/controlify/font/InputFontMapper.java rename to src/main/java/dev/isxander/controlify/impl/font/InputFontMapper.java index d7d4322ad..e5026c957 100644 --- a/src/main/java/dev/isxander/controlify/font/InputFontMapper.java +++ b/src/main/java/dev/isxander/controlify/impl/font/InputFontMapper.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.font; +package dev.isxander.controlify.impl.font; import com.google.common.collect.ImmutableMap; import com.google.gson.JsonElement; @@ -13,11 +13,16 @@ import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.action.Gesture; +import dev.isxander.controlify.impl.action.ActionImpl; +import dev.isxander.controlify.impl.action.activator.LeafConfig; +import net.minecraft.ChatFormatting; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.FontDescription; import net.minecraft.network.chat.MutableComponent; @@ -28,8 +33,8 @@ import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; -import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.stream.Collectors; @@ -54,7 +59,7 @@ public class InputFontMapper implements SimpleControlifyReloadListener load(ResourceManager manager, Executor executor) { @@ -75,7 +80,7 @@ public CompletableFuture load(ResourceManager manager, Executor ex return Stream.of(Pair.of(namespace, map)); } } catch (Exception e) { - CUtil.LOGGER.error("Failed to load font mappings for namespace {}", namespace, e); + CUtil.LOGGER.error("Failed to load font mappings for id {}", namespace, e); } return Stream.empty(); @@ -91,7 +96,7 @@ public CompletableFuture apply(Preparations data, ResourceManager manager, ImmutableMap.Builder builder = ImmutableMap.builder(); data.mappings().forEach(builder::put); mappings = builder.build(); - defaultFontMap = mappings.get(ControllerType.DEFAULT.namespace()); + defaultFontMap = mappings.get(ControllerType.DEFAULT.id()); }, executor); } @@ -99,51 +104,88 @@ public FontMap getMappings(Identifier namespace) { return mappings.getOrDefault(namespace, defaultFontMap); } - public Component getComponentFromBinding(Identifier namespace, @Nullable InputBinding binding) { - if (binding == null) { + /// The action's primary binding, as shown in prompts. Its gesture is only spelled out when the action + /// lets bindings choose one and another currently active action shares the input, since the glyph + /// alone would not say which of them a press triggers. + public Component getComponentFromAction(Identifier namespace, @Nullable Action action) { + if (action == null || action.bindings().isEmpty()) { // TODO: implement some sort of "unbound" character return Component.literal("?"); } - List relevantInputs = binding.boundInput().getRelevantInputs(); - return getComponentFromInputs(namespace, relevantInputs); + ActionBinding primary = action.bindings().getFirst(); + Optional shownGesture = editableLeaf(action) + .filter(_ -> action instanceof ActionImpl impl && impl.isInputShared(primary.input())); + return getComponentFromBinding(namespace, primary, shownGesture); + } + + /// The binding's input glyph followed by its gesture, such as "A (Hold)". + /// The gesture is only shown when {@code editableLeaf} is present, since it is otherwise fixed by the action. + public Component getComponentFromBinding(Identifier namespace, @Nullable ActionBinding binding, Optional editableLeaf) { + if (binding == null) { + return getComponentFromInput(namespace, null); + } + Component input = getComponentFromInput(namespace, binding.input()); + return editableLeaf.flatMap(leaf -> leaf.gestureFor(binding)) + .map(gesture -> Component.empty() + .append(input) + .append(Component.literal(" ").withStyle(style -> style.withFont(FontDescription.DEFAULT))) + .append(gestureLabel(gesture))) + .orElse(input); + } + + public static Optional editableLeaf(Action action) { + return action instanceof ActionImpl impl ? impl.activatorConfig().editableLeaf() : Optional.empty(); } - public Component getComponentFromBind(Identifier namespace, Input input) { - List relevantInputs = input.getRelevantInputs(); - return getComponentFromInputs(namespace, relevantInputs); + public static Component gestureLabel(Gesture gesture) { + return Component.translatable("controlify.gesture." + gesture.getSerializedName()) + .withStyle(style -> style.withFont(FontDescription.DEFAULT).withColor(ChatFormatting.GRAY)); } - public Component getComponentFromInputs(Identifier namespace, List inputs) { - if (inputs.isEmpty()) { + public Component getComponentFromInput(Identifier namespace, @Nullable LogicalInput input) { + if (input == null) { return Component.literal(""); } FontMap fontMap = getMappings(namespace); - MutableComponent component = Component.empty(); - StringBuilder literal = new StringBuilder(); - FontMap currentFontMap = null; + return switch (input) { + case LogicalInput.Single(var physicalInput) -> { + MappedChar mappedChar = getChar(fontMap, physicalInput); + yield createComponent(String.valueOf(mappedChar.character()), mappedChar.fontMap()); + } + case LogicalInput.ModifiedChord(var modifiers, var trigger) -> { + MutableComponent component = Component.empty(); + StringBuilder literal = new StringBuilder(); + FontMap currentFontMap = null; + + // Coalesce adjacent modifiers using the same font into a single literal. + for (Identifier modifier : modifiers) { + MappedChar mappedChar = getChar(fontMap, modifier); + if (currentFontMap != null && currentFontMap != mappedChar.fontMap()) { + MutableComponent run = createComponent(literal.toString(), currentFontMap); + component.append(run); + literal.setLength(0); + } + currentFontMap = mappedChar.fontMap(); + literal.append(mappedChar.character()); + } - for (int i = 0; i < inputs.size(); i++) { - MappedChar mappedChar = getChar(fontMap, inputs.get(i)); - if (currentFontMap != mappedChar.fontMap()) { if (currentFontMap != null) { - component.append(createComponent(literal.toString(), currentFontMap)); - literal.setLength(0); + MutableComponent run = createComponent(literal.toString(), currentFontMap); + // Use the first run as the root to avoid an empty wrapper component. + component.append(run); + component.append(Component.literal("+").withStyle(style -> style.withFont(FontDescription.DEFAULT))); } - currentFontMap = mappedChar.fontMap(); - } - if (i > 0) { - literal.append('+'); + MappedChar mappedTrigger = getChar(fontMap, trigger); + MutableComponent triggerComponent = createComponent(String.valueOf(mappedTrigger.character()), mappedTrigger.fontMap()); + yield component.append(triggerComponent); } - literal.append(mappedChar.character()); - } - - return component.append(createComponent(literal.toString(), currentFontMap)); + }; } - private Component createComponent(String literal, FontMap fontMap) { + private MutableComponent createComponent(String literal, FontMap fontMap) { return Component.literal(literal).withStyle(style -> style .withFont(new FontDescription.Resource(fontMap.namespace().withPrefix("controller/"))) .withShadowColor(0x00000000) // remove shadow @@ -171,7 +213,6 @@ public Identifier getReloadId() { } public record Preparations(Map mappings) { - } public record FontMap(Identifier namespace, char unknown, Map inputToChar) { } diff --git a/src/main/java/dev/isxander/controlify/impl/gui/ButtonGuideApiImpl.java b/src/main/java/dev/isxander/controlify/impl/gui/ButtonGuideApiImpl.java new file mode 100644 index 000000000..58c6d4c75 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/gui/ButtonGuideApiImpl.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.gui; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.gui.ButtonGuideApi; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import org.jspecify.annotations.NonNull; + +import java.util.function.Supplier; + +public final class ButtonGuideApiImpl implements ButtonGuideApi { + public static final ButtonGuideApi INSTANCE = new ButtonGuideApiImpl(); + + private ButtonGuideApiImpl() { + } + + @Override + public void addGuideToButton(@NonNull T button, @NonNull ActionAccessor action, @NonNull ButtonGuidePredicate renderPredicate) { + addGuideToButton(button, () -> action, renderPredicate); + } + + @Override + public void addGuideToButton(@NonNull T button, @NonNull Supplier action, @NonNull ButtonGuidePredicate renderPredicate) { + ButtonGuideRenderer.registerActionForButton(button, action, renderPredicate); + } +} diff --git a/src/main/java/dev/isxander/controlify/gui/ButtonGuideRenderer.java b/src/main/java/dev/isxander/controlify/impl/gui/ButtonGuideRenderer.java similarity index 57% rename from src/main/java/dev/isxander/controlify/gui/ButtonGuideRenderer.java rename to src/main/java/dev/isxander/controlify/impl/gui/ButtonGuideRenderer.java index 071db5fad..18ac76d0a 100644 --- a/src/main/java/dev/isxander/controlify/gui/ButtonGuideRenderer.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/ButtonGuideRenderer.java @@ -4,12 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui; +package dev.isxander.controlify.impl.gui; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.api.bind.InputBindingSupplier; +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.impl.Controlify; import net.minecraft.client.Minecraft; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; @@ -23,36 +24,36 @@ public interface ButtonGuideRenderer { void controlify$setButtonGuide(RenderData renderData); - static void registerBindingForButton(T button, Supplier binding, ButtonGuidePredicate renderPredicate) { - ((ButtonGuideRenderer) button).controlify$setButtonGuide(new RenderData<>(binding, renderPredicate)); + static void registerActionForButton(T button, Supplier action, ButtonGuidePredicate renderPredicate) { + ((ButtonGuideRenderer) button).controlify$setButtonGuide(new RenderData<>(action, renderPredicate)); } - record RenderData(Supplier binding, ButtonGuidePredicate renderPredicate) { - public Component getControllerMessage(InputBinding bind, Component actualLabel) { + record RenderData(Supplier action, ButtonGuidePredicate renderPredicate) { + public Component getControllerMessage(Action action, Component actualLabel) { var component = Component.empty(); if (!Minecraft.getInstance().font.isBidirectional()) { - component.append(bind.inputGlyph()); + component.append(ActionGlyphs.action(action.spec().id())); component.append(CommonComponents.SPACE); } component.append(actualLabel); if (Minecraft.getInstance().font.isBidirectional()) { component.append(CommonComponents.SPACE); - component.append(bind.inputGlyph()); + component.append(ActionGlyphs.action(action.spec().id())); } return component; } public boolean shouldRender(T renderable) { - Optional binding = getBind(); + Optional binding = getAction(); return binding.isPresent() && Controlify.instance().currentInputMode().isController() && Controlify.instance().getCurrentController().map(c -> c.settings().generic.guide.showScreenGuides).orElse(false) - && !binding.get().isUnbound() + && binding.get().primaryInput() != null && renderPredicate().shouldDisplay(renderable); } - public Optional getBind() { - return Controlify.instance().getCurrentController().map(c -> binding().get().on(c)); + public Optional getAction() { + return Controlify.instance().getCurrentController().map(c -> action().get().on(c)); } } } diff --git a/src/main/java/dev/isxander/controlify/gui/components/FakePositionPlainTextButton.java b/src/main/java/dev/isxander/controlify/impl/gui/components/FakePositionPlainTextButton.java similarity index 95% rename from src/main/java/dev/isxander/controlify/gui/components/FakePositionPlainTextButton.java rename to src/main/java/dev/isxander/controlify/impl/gui/components/FakePositionPlainTextButton.java index 4bb2e35d9..22bc4399a 100644 --- a/src/main/java/dev/isxander/controlify/gui/components/FakePositionPlainTextButton.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/components/FakePositionPlainTextButton.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.components; +package dev.isxander.controlify.impl.gui.components; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.components.PlainTextButton; diff --git a/src/main/java/dev/isxander/controlify/gui/components/PlainTextWidget.java b/src/main/java/dev/isxander/controlify/impl/gui/components/PlainTextWidget.java similarity index 96% rename from src/main/java/dev/isxander/controlify/gui/components/PlainTextWidget.java rename to src/main/java/dev/isxander/controlify/impl/gui/components/PlainTextWidget.java index c4d806498..b6e7cee13 100644 --- a/src/main/java/dev/isxander/controlify/gui/components/PlainTextWidget.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/components/PlainTextWidget.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.components; +package dev.isxander.controlify.impl.gui.components; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.ComponentPath; diff --git a/src/main/java/dev/isxander/controlify/impl/gui/controllers/BindController.java b/src/main/java/dev/isxander/controlify/impl/gui/controllers/BindController.java new file mode 100644 index 000000000..360c77f1d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/gui/controllers/BindController.java @@ -0,0 +1,335 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.gui.controllers; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.action.Gesture; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.BindingConflicts; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.action.activator.LeafConfig; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.gui.screen.BindConsumerScreen; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.yacl3.api.Controller; +import dev.isxander.yacl3.api.Option; +import dev.isxander.yacl3.api.utils.Dimension; +import dev.isxander.yacl3.gui.YACLScreen; +import dev.isxander.yacl3.gui.controllers.ControllerWidget; +import dev.isxander.yacl3.gui.utils.GuiUtils; +import net.minecraft.ChatFormatting; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/// Edits all of an action's bindings in one row: the action name, then a button per binding slot. +public class BindController implements Controller> { + public static final int SLOTS = 2; + + private final Option> option; + public final @Nullable ControllerImpl controller; + /// The leaf a binding may override the gesture of, if the action allows it. + private final Optional editableLeaf; + private final BindingConflicts.@Nullable Kind[] slotConflicts = new BindingConflicts.Kind[SLOTS]; + private List conflictLines = List.of(); + + public BindController(Option> option, @Nullable ControllerImpl controller, Optional editableLeaf) { + this.option = option; + this.controller = controller; + this.editableLeaf = editableLeaf; + } + + @Override + public Option> option() { + return this.option; + } + + @Override + public Component formatValue() { + return Component.empty(); + } + + /// @param slotConflicts the most severe conflict on each slot, or null for none + /// @param conflictLines explanations shown in the description + public void setConflicts(BindingConflicts.@Nullable Kind[] slotConflicts, List conflictLines) { + System.arraycopy(slotConflicts, 0, this.slotConflicts, 0, SLOTS); + this.conflictLines = List.copyOf(conflictLines); + } + + public List conflictLines() { + return this.conflictLines; + } + + public Optional slot(int slot) { + List bindings = this.option.pendingValue(); + return slot < bindings.size() ? Optional.of(bindings.get(slot)) : Optional.empty(); + } + + /// Clearing a slot moves later bindings up, so filled slots always come first. + public void setSlot(int slot, Optional binding) { + List bindings = new ArrayList<>(this.option.pendingValue()); + if (slot < bindings.size()) { + if (binding.isPresent()) { + bindings.set(slot, binding.get()); + } else { + bindings.remove(slot); + } + } else { + binding.ifPresent(bindings::add); + } + this.option.requestSet(List.copyOf(bindings)); + } + + public boolean canEditGesture(int slot) { + return this.editableLeaf.isPresent() && this.slot(slot).isPresent(); + } + + /// Steps through every gesture of the editable leaf's kind, starting from the one in effect. + /// Choosing the leaf's own gesture stores no override. + public void cycleGesture(int slot) { + if (!this.canEditGesture(slot)) { + return; + } + LeafConfig leaf = this.editableLeaf.get(); + Gesture defaultGesture = leaf.gesture().orElseThrow(); + List choices = Arrays.stream(Gesture.values()) + .filter(gesture -> gesture.kind() == defaultGesture.kind()) + // GUI press is tied to GUI navigation, which only GUI actions have. + .filter(gesture -> gesture != Gesture.GUI_PRESS || gesture == defaultGesture) + .toList(); + ActionBinding binding = this.slot(slot).orElseThrow(); + Gesture current = leaf.gestureFor(binding).orElse(defaultGesture); + Gesture next = choices.get((choices.indexOf(current) + 1) % choices.size()); + this.setSlot(slot, Optional.of(binding.withGesture(next == defaultGesture ? Optional.empty() : Optional.of(next)))); + } + + private Component slotText(int slot) { + Identifier fontNamespace = this.controller == null ? ControllerType.DEFAULT.id() : this.controller.info().type().id(); + return this.slot(slot) + .map(binding -> Controlify.instance().inputFontMapper().getComponentFromBinding(fontNamespace, binding, this.editableLeaf)) + .orElseGet(() -> Component.literal("-").withStyle(ChatFormatting.DARK_GRAY)); + } + + @Override + public BindControllerElement provideWidget(YACLScreen yaclScreen, Dimension dimension) { + return new BindControllerElement(this, yaclScreen, dimension); + } + + public static class BindControllerElement extends ControllerWidget implements ElementBehaviour { + private static final int SLOT_GAP = 2; + + public boolean awaitingControllerInput = false; + private final Component awaitingText = Component.translatable("controlify.gui.bind_input_awaiting").withStyle(ChatFormatting.ITALIC); + /// The slot keyboard and controller input act on. The mouse acts on whichever slot it is over. + private int selectedSlot = 0; + + public BindControllerElement(BindController control, YACLScreen screen, Dimension dim) { + super(control, screen, dim); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + hovered = isMouseOver(mouseX, mouseY); + + // Unlike other options, the name sits outside any button so each slot reads as its own button. + Component name = control.option().changed() ? modifiedOptionName : control.option().name(); + int nameWidth = getDimension().width() - getControlWidth() - getXPadding() * 2; + Component shortenedName = Component.literal(GuiUtils.shortenString(name.getString(), textRenderer, nameWidth, "...")) + .setStyle(name.getStyle()); + graphics.text(textRenderer, shortenedName, getDimension().x() + getXPadding(), getTextY(), isAvailable() ? -1 : inactiveColor, true); + + for (int slot = 0; slot < SLOTS; slot++) { + int x1 = slotX(slot); + int x2 = x1 + slotWidth(); + boolean highlighted = isOverSlot(slot, mouseX, mouseY) || focused && slot == selectedSlot; + // The button sprite is greyscale, so tinting it marks conflicts without touching the glyphs, + // which always render in their own colours. + graphics.blitSprite( + RenderPipelines.GUI_TEXTURED, + GuiUtils.BUTTON_SPRITES.get(isAvailable(), highlighted && isAvailable()), + x1, getDimension().y(), + x2 - x1, getDimension().height(), + slotTint(slot) + ); + + Component text = awaitingControllerInput && slot == selectedSlot ? awaitingText : control.slotText(slot); + Component shortened = textRenderer.width(text) > slotWidth() - 4 + ? Component.literal(GuiUtils.shortenString(text.getString(), textRenderer, slotWidth() - 4, "...")).setStyle(text.getStyle()) + : text; + int textX = x1 + (slotWidth() - textRenderer.width(shortened)) / 2; + graphics.text(textRenderer, shortened, textX, getTextY() + 1, isAvailable() ? -1 : inactiveColor, true); + } + } + + private int slotWidth() { + return Math.min(100, (getDimension().width() / 2 - SLOT_GAP) / SLOTS); + } + + private int slotX(int slot) { + int firstX = getDimension().xLimit() - SLOTS * slotWidth() - (SLOTS - 1) * SLOT_GAP; + return firstX + slot * (slotWidth() + SLOT_GAP); + } + + private boolean isOverSlot(int slot, double mouseX, double mouseY) { + int x1 = slotX(slot); + return mouseX >= x1 && mouseX < x1 + slotWidth() + && mouseY >= getDimension().y() && mouseY < getDimension().yLimit(); + } + + private int slotTint(int slot) { + BindingConflicts.Kind conflict = control.slotConflicts[slot]; + if (conflict == null || !isAvailable()) { + return -1; + } + return switch (conflict) { + case CONFLICT -> 0xFFFF6060; + case MODIFIER_LEAK -> 0xFFFFB040; + case REDUNDANT, SHADOWED -> 0xFFA0A0A0; + }; + } + + /// The area the bind capture screen leaves undimmed: the slot being rebound. + public Dimension awaitingSlotDimension() { + return Dimension.ofInt(slotX(selectedSlot), getDimension().y(), slotWidth(), getDimension().height()); + } + + @Override + public boolean keyPressed(KeyEvent keyEvent) { + if (!isFocused()) { + return false; + } + switch (keyEvent.key()) { + case InputConstants.KEY_RETURN -> openConsumerScreen(selectedSlot); + // Past either end, let focus move on, such as to the reset button. + case InputConstants.KEY_LEFT -> { + return moveSlot(-1); + } + case InputConstants.KEY_RIGHT -> { + return moveSlot(1); + } + default -> { + return false; + } + } + return true; + } + + @Override + public boolean mouseClicked(MouseButtonEvent mouseButtonEvent, boolean doubleClick) { + for (int slot = 0; slot < SLOTS; slot++) { + if (isOverSlot(slot, mouseButtonEvent.x(), mouseButtonEvent.y())) { + selectedSlot = slot; + if (mouseButtonEvent.button() == InputConstants.MOUSE_BUTTON_RIGHT) { + control.cycleGesture(slot); + } else { + openConsumerScreen(slot); + } + return true; + } + } + return false; + } + + private void openConsumerScreen(int slot) { + selectedSlot = slot; + awaitingControllerInput = true; + MinecraftUtil.setScreen(new BindConsumerScreen( + control.controller != null ? control.controller : Controlify.instance().getCurrentController().orElse(null), + control.slot(slot), + binding -> control.setSlot(slot, binding), + this, + MinecraftUtil.getScreen() + )); + } + + @Override + public void handleNavigation(@NonNull ScreenContext screen, dev.isxander.controlify.api.controller.@NonNull Controller controller, @NonNull Runnable next) { + // Horizontal navigation moves between slots; the option list only scrolls vertically. + // Past either end, the pulse is left for the screen to move focus on, such as to the reset button. + if (controller == control.controller && focused) { + if (selectedSlot > 0 && ControlifyActions.GUI_NAVI_LEFT.on(controller).pulse().consumeAllPulses()) { + moveSlot(-1); + return; + } + if (selectedSlot < SLOTS - 1 && ControlifyActions.GUI_NAVI_RIGHT.on(controller).pulse().consumeAllPulses()) { + moveSlot(1); + return; + } + } + next.run(); + } + + @Override + public void handleInteraction(@NonNull ScreenContext screen, dev.isxander.controlify.api.controller.@NonNull Controller controller, @NonNull Runnable next) { + if (controller != control.controller) { + next.run(); + return; + } + + if (ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses()) { + openConsumerScreen(selectedSlot); + return; + } + if (control.canEditGesture(selectedSlot) && ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + control.cycleGesture(selectedSlot); + return; + } + + next.run(); + } + + /// @return whether the selection moved; false at either end + private boolean moveSlot(int direction) { + int target = selectedSlot + direction; + if (target < 0 || target >= SLOTS) { + return false; + } + selectedSlot = target; + return true; + } + + @Override + public void setFocused(boolean focused) { + super.setFocused(focused); + // Each row starts on its first slot. Rebinding takes focus away briefly, so keep the slot then. + if (!focused && !awaitingControllerInput) { + selectedSlot = 0; + } + } + + @Override + public void unfocus() { + super.unfocus(); + if (!awaitingControllerInput) { + selectedSlot = 0; + } + } + + @Override + protected int getHoveredControlWidth() { + return getUnhoveredControlWidth(); + } + + @Override + protected int getUnhoveredControlWidth() { + return SLOTS * slotWidth() + (SLOTS - 1) * SLOT_GAP; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/gui/controllers/BindingInfoImageRenderer.java b/src/main/java/dev/isxander/controlify/impl/gui/controllers/BindingInfoImageRenderer.java new file mode 100644 index 000000000..644e7970c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/gui/controllers/BindingInfoImageRenderer.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.gui.controllers; + +import dev.isxander.yacl3.gui.image.ImageRenderer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; + +import java.util.List; +import java.util.function.Supplier; + +/// Option descriptions are only rebuilt when their own option changes, but binding conflicts +/// depend on every other binding. Rendering the lines each frame keeps them current. +public class BindingInfoImageRenderer implements ImageRenderer { + private final Supplier> lines; + + public BindingInfoImageRenderer(Supplier> lines) { + this.lines = lines; + } + + @Override + public int render(GuiGraphicsExtractor graphics, int x, int y, int renderWidth, float tickDelta) { + Font font = Minecraft.getInstance().font; + int height = 0; + + List lines = this.lines.get(); + for (int i = 0; i < lines.size(); i++) { + if (i == 0 || i == lines.size() - 1) { + height += 5; // add padding + } + + Component line = lines.get(i); + + for (FormattedCharSequence wrapped : font.split(line, renderWidth)) { + graphics.text(font, wrapped, x, y + height, -1, true); + height += font.lineHeight + 2; + } + } + + return height; + } + + @Override + public void close() { + } +} diff --git a/src/main/java/dev/isxander/controlify/gui/controllers/Deadzone2DImageRenderer.java b/src/main/java/dev/isxander/controlify/impl/gui/controllers/Deadzone2DImageRenderer.java similarity index 61% rename from src/main/java/dev/isxander/controlify/gui/controllers/Deadzone2DImageRenderer.java rename to src/main/java/dev/isxander/controlify/impl/gui/controllers/Deadzone2DImageRenderer.java index 450b28ae8..f2255e26a 100644 --- a/src/main/java/dev/isxander/controlify/gui/controllers/Deadzone2DImageRenderer.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/controllers/Deadzone2DImageRenderer.java @@ -4,31 +4,37 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.controllers; +package dev.isxander.controlify.impl.gui.controllers; -import dev.isxander.controlify.controller.input.DeadzoneGroup; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.utils.render.elements.CircleElementRenderState; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.input.mapping.DeadzoneGroup; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.impl.utils.render.elements.CircleElementRenderState; import dev.isxander.yacl3.api.Option; import dev.isxander.yacl3.gui.image.ImageRenderer; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.resources.Identifier; import java.text.DecimalFormat; -import java.util.List; +import java.util.Arrays; import java.util.function.Supplier; public class Deadzone2DImageRenderer implements ImageRenderer { - private final InputComponent input; private final DeadzoneGroup deadzoneGroup; private final Supplier> deadzoneOption; - public Deadzone2DImageRenderer(InputComponent input, DeadzoneGroup deadzoneGroup, Supplier> deadzoneOption) { - this.input = input; + private final float[] directions; + private final EventSource.Subscription pipelineSubscription; + + public Deadzone2DImageRenderer(InputComponentImpl input, DeadzoneGroup deadzoneGroup, Supplier> deadzoneOption) { this.deadzoneGroup = deadzoneGroup; this.deadzoneOption = deadzoneOption; + this.directions = new float[4]; + Arrays.fill(this.directions, 0f); + this.pipelineSubscription = input.inputPipeline().sourceRawPhysicalSignals().subscribe(this::onSignal); } @Override @@ -37,11 +43,10 @@ public int render(GuiGraphicsExtractor graphics, int x, int y, int renderWidth, int renderHeight = (int) (radius * 2f); // axes go up -> down -> left -> right - List deadzones = deadzoneGroup.axes(); - float up = input.rawStateNow().getAxisState(deadzones.get(0)); - float down = input.rawStateNow().getAxisState(deadzones.get(1)); - float left = input.rawStateNow().getAxisState(deadzones.get(2)); - float right = input.rawStateNow().getAxisState(deadzones.get(3)); + float up = this.directions[0]; + float down = this.directions[1]; + float left = this.directions[2]; + float right = this.directions[3]; float currentX = right - left; float currentY = down - up; @@ -82,8 +87,17 @@ public int render(GuiGraphicsExtractor graphics, int x, int y, int renderWidth, return renderHeight; } + private void onSignal(PhysicalSignal signal) { + if (signal instanceof PhysicalInputSignal.AxisMoved axisMoved) { + int index = this.deadzoneGroup.axes().indexOf(axisMoved.input()); + if (index != -1) { + this.directions[index] = axisMoved.value(); + } + } + } + @Override public void close() { - + this.pipelineSubscription.close(); } } diff --git a/src/main/java/dev/isxander/controlify/gui/guide/GuideInstanceImpl.java b/src/main/java/dev/isxander/controlify/impl/gui/guide/GuideInstanceImpl.java similarity index 66% rename from src/main/java/dev/isxander/controlify/gui/guide/GuideInstanceImpl.java rename to src/main/java/dev/isxander/controlify/impl/gui/guide/GuideInstanceImpl.java index 1b0444259..9b36336bf 100644 --- a/src/main/java/dev/isxander/controlify/gui/guide/GuideInstanceImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/guide/GuideInstanceImpl.java @@ -4,23 +4,25 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.guide; +package dev.isxander.controlify.impl.gui.guide; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.contextual.GuideLocation; +import dev.isxander.controlify.api.action.Action; import dev.isxander.controlify.api.contextual.Context; import dev.isxander.controlify.api.contextual.GuideInstance; -import dev.isxander.controlify.contextual.ContextualDomainImpl; -import dev.isxander.controlify.contextual.ContextualState; -import dev.isxander.controlify.contextual.GuideRule; -import dev.isxander.controlify.contextual.RuleEngine; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.contextual.ContextualDomainImpl; +import dev.isxander.controlify.impl.contextual.ContextualState; +import dev.isxander.controlify.impl.contextual.GuideLocation; +import dev.isxander.controlify.impl.contextual.GuideRule; +import dev.isxander.controlify.impl.contextual.RuleEngine; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.font.ComponentFontHelper; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Renderable; import net.minecraft.network.chat.Component; +import org.jspecify.annotations.NonNull; import java.util.List; import java.util.function.Supplier; @@ -44,7 +46,7 @@ public GuideInstanceImpl( } @Override - public void update(C context) { + public void update(@NonNull C context) { RuleEngine ruleEngine = this.ruleEngineSupplier.get(); ContextualState state = this.domain.calculateState(context, ruleEngine.factDependencies()); List guides = ruleEngine.evaluate(state, _ -> true, "guide/" + this.domain.id()); @@ -52,12 +54,12 @@ public void update(C context) { var leftBuilder = new PrecomputedLines.Builder(); var rightBuilder = new PrecomputedLines.Builder(); - ControllerEntity controller = context.controller(); + var controller = (ControllerImpl) context.controller(); for (GuideRule guide : guides) { - InputBinding binding = guide.binding().onOrNull(controller); - if (binding == null || binding.isUnbound()) { - // skip this rule if the binding is not bound + Action action = guide.action().onOrNull(controller); + if (action == null || action.primaryInput() == null) { + // skip this rule if the action is not bound continue; } @@ -66,14 +68,14 @@ public void update(C context) { case RIGHT -> rightBuilder; }; - // put the glyph after or before the binding glyph based on the rule's location and font direction + // put the glyph after or before the action glyph based on the rule's location and font direction boolean glyphAfter = font.isBidirectional() ^ (guide.location() == GuideLocation.RIGHT); // formulate the text to display Component text = Component.empty() - .append(glyphAfter ? guide.text() : binding.inputGlyph()) + .append(glyphAfter ? guide.text() : ActionGlyphs.action(action.spec().id())) .append(" ") - .append(glyphAfter ? binding.inputGlyph() : guide.text()); + .append(glyphAfter ? ActionGlyphs.action(action.spec().id()) : guide.text()); // precompute the width and height of the text int ruleNameWidth = font.width(guide.text()); @@ -82,7 +84,7 @@ public void update(C context) { int backgroundLeft = glyphAfter ? 0 : glyphWidth; int backgroundRight = backgroundLeft + ruleNameWidth; - int height = BindingFontHelper.getComponentHeight(font, binding.inputGlyph()); // use input glyph height only as it's bound to be the tallest + int height = ComponentFontHelper.getComponentHeight(font, ActionGlyphs.action(action.spec().id())); // use input glyph height only as it's bound to be the tallest builder.addLine(text, width, height, backgroundLeft, backgroundRight); } @@ -91,7 +93,7 @@ public void update(C context) { } @Override - public void extractRenderState(GuiGraphicsExtractor graphics, boolean bottomAligned, boolean textContrast, int guiScale) { + public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, boolean bottomAligned, boolean textContrast, int guiScale) { GuideRenderer.extractRenderState( graphics, this, diff --git a/src/main/java/dev/isxander/controlify/gui/guide/GuideRenderer.java b/src/main/java/dev/isxander/controlify/impl/gui/guide/GuideRenderer.java similarity index 98% rename from src/main/java/dev/isxander/controlify/gui/guide/GuideRenderer.java rename to src/main/java/dev/isxander/controlify/impl/gui/guide/GuideRenderer.java index d0864217d..aa3ab56c9 100644 --- a/src/main/java/dev/isxander/controlify/gui/guide/GuideRenderer.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/guide/GuideRenderer.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.guide; +package dev.isxander.controlify.impl.gui.guide; import com.google.common.collect.Lists; import com.mojang.blaze3d.platform.Window; diff --git a/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java b/src/main/java/dev/isxander/controlify/impl/gui/guide/InGameButtonGuide.java similarity index 72% rename from src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java rename to src/main/java/dev/isxander/controlify/impl/gui/guide/InGameButtonGuide.java index e29e7182c..ad2d20cc9 100644 --- a/src/main/java/dev/isxander/controlify/gui/guide/InGameButtonGuide.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/guide/InGameButtonGuide.java @@ -4,24 +4,23 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.guide; +package dev.isxander.controlify.impl.gui.guide; import dev.isxander.controlify.api.contextual.GuideInstance; -import dev.isxander.controlify.config.settings.profile.GenericControllerSettings; -import dev.isxander.controlify.contextual.ContextualDomains; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.impl.config.settings.profile.GenericControllerSettings; +import dev.isxander.controlify.impl.contextual.ContextualDomains; +import dev.isxander.controlify.impl.controller.ControllerImpl; import dev.isxander.controlify.api.contextual.InGameContext; -import dev.isxander.controlify.mixins.feature.guide.ingame.MinecraftAccessor; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; public class InGameButtonGuide { - private final ControllerEntity controller; + private final ControllerImpl controller; private final Minecraft minecraft; private final GuideInstance guideInstance; - public InGameButtonGuide(ControllerEntity controller, Minecraft minecraft) { + public InGameButtonGuide(ControllerImpl controller, Minecraft minecraft) { this.controller = controller; this.minecraft = minecraft; this.guideInstance = ContextualDomains.INSTANCE.inGame().createGuideInstance(minecraft.font); @@ -32,8 +31,8 @@ public void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta) { //? if >=26.2 { boolean hideGui = minecraft.gui.hud.isHidden(); //?} else { - /*boolean hideGui = minecraft.options.hideGui; - *///?} + //boolean hideGui = minecraft.options.hideGui; + //?} boolean screenOpen = MinecraftUtil.getScreen() != null; GenericControllerSettings.GuideSettings settings = controller.settings().generic.guide; diff --git a/src/main/java/dev/isxander/controlify/gui/guide/PrecomputedLines.java b/src/main/java/dev/isxander/controlify/impl/gui/guide/PrecomputedLines.java similarity index 95% rename from src/main/java/dev/isxander/controlify/gui/guide/PrecomputedLines.java rename to src/main/java/dev/isxander/controlify/impl/gui/guide/PrecomputedLines.java index b8465f8b4..db2d162ba 100644 --- a/src/main/java/dev/isxander/controlify/gui/guide/PrecomputedLines.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/guide/PrecomputedLines.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.guide; +package dev.isxander.controlify.impl.gui.guide; import net.minecraft.network.chat.Component; diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/ChatKeyboardDucky.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/ChatKeyboardDucky.java similarity index 88% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/ChatKeyboardDucky.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/ChatKeyboardDucky.java index 3d297b666..4cbf6e032 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/ChatKeyboardDucky.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/ChatKeyboardDucky.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; import net.minecraft.client.gui.screens.ChatScreen; diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/CommonKeyboardHints.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/CommonKeyboardHints.java similarity index 52% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/CommonKeyboardHints.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/CommonKeyboardHints.java index 7a215fbef..5bf6414d4 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/CommonKeyboardHints.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/CommonKeyboardHints.java @@ -4,24 +4,26 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.utils.LazyComponentDims; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.utils.LazyComponentDims; import net.minecraft.network.chat.Component; public final class CommonKeyboardHints { public static final LazyComponentDims TEXT_CURSOR = new LazyComponentDims( Component.translatable("controlify.hint.keyboard_cursor_movement", - ControlifyBindings.GUI_PREV_TAB.inputGlyph(), - ControlifyBindings.GUI_NEXT_TAB.inputGlyph()) + ActionGlyphs.action(ControlifyActions.GUI_PREV_TAB.actionId()), + ActionGlyphs.action(ControlifyActions.GUI_NEXT_TAB.actionId())) ); public static final LazyComponentDims OPEN_KEYBOARD = new LazyComponentDims( Component.translatable("controlify.hint.edit_box_keyboard", - ControlifyBindings.GUI_PRESS.inputGlyph()) + ActionGlyphs.action(ControlifyActions.GUI_PRESS.actionId())) ); - private CommonKeyboardHints() {} + private CommonKeyboardHints() { + } } diff --git a/src/main/java/dev/isxander/controlify/impl/gui/keyboard/DelegatingInputTarget.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/DelegatingInputTarget.java new file mode 100644 index 000000000..1288e8bf8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/DelegatingInputTarget.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.gui.keyboard; + +import dev.isxander.controlify.api.gui.keyboard.InputTarget; + +public class DelegatingInputTarget implements InputTarget { + private final InputTarget target; + + public DelegatingInputTarget(InputTarget target) { + this.target = target; + } + + @Override + public boolean supportsCharInput() { + return this.target.supportsCharInput(); + } + + @Override + public boolean acceptChar(char ch, int modifiers) { + return this.target.acceptChar(ch, modifiers); + } + + @Override + public boolean supportsKeyCodeInput() { + return this.target.supportsKeyCodeInput(); + } + + @Override + public boolean acceptKeyCode(int keycode, int scancode, int modifiers) { + return this.target.acceptKeyCode(keycode, scancode, modifiers); + } + + @Override + public boolean supportsCopying() { + return this.target.supportsCopying(); + } + + @Override + public boolean copy() { + return this.target.copy(); + } + + @Override + public boolean supportsCursorMovement() { + return this.target.supportsCursorMovement(); + } + + @Override + public boolean moveCursor(int amount) { + return this.target.moveCursor(amount); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/gui/keyboard/EditBoxInputTarget.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/EditBoxInputTarget.java new file mode 100644 index 000000000..9be153296 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/EditBoxInputTarget.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.gui.keyboard; + +import dev.isxander.controlify.api.gui.keyboard.InputTarget; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; + +public record EditBoxInputTarget(EditBox editBox) implements InputTarget { + + @Override + public boolean supportsCharInput() { + return true; + } + + @Override + public boolean acceptChar(char ch, int modifiers) { + this.editBox.charTyped(new CharacterEvent(ch)); + return true; + } + + @Override + public boolean supportsKeyCodeInput() { + return true; + } + + @Override + public boolean acceptKeyCode(int keycode, int scancode, int modifiers) { + this.editBox.keyPressed(new KeyEvent(keycode, scancode, modifiers)); + return true; + } + + @Override + public boolean supportsCursorMovement() { + return true; + } + + @Override + public boolean moveCursor(int amount) { + this.editBox.moveCursor(amount, false); + return true; + } + + @Override + public boolean supportsCopying() { + return true; + } + + @Override + public boolean copy() { + Minecraft.getInstance().keyboardHandler.setClipboard(this.editBox.getValue()); + return true; + } +} diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/FallbackKeyboardLayout.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/FallbackKeyboardLayout.java similarity index 74% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/FallbackKeyboardLayout.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/FallbackKeyboardLayout.java index 72c6100af..2c1813b4b 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/FallbackKeyboardLayout.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/FallbackKeyboardLayout.java @@ -4,19 +4,19 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.Optional; -import static dev.isxander.controlify.screenop.keyboard.KeyboardLayout.Key; -import static dev.isxander.controlify.screenop.keyboard.KeyboardLayout.KeyFunction.*; +import static dev.isxander.controlify.impl.gui.keyboard.KeyboardLayout.Key; +import static dev.isxander.controlify.impl.gui.keyboard.KeyboardLayout.KeyFunction.*; /** * A code-defined fallback keyboard layout if the resource-pack driven ones @@ -38,7 +38,7 @@ public final class FallbackKeyboardLayout { k("i"), k("o"), k("p"), - k(SpecialFunc.Action.BACKSPACE, 2.0f, ControlifyBindings.GUI_ABSTRACT_ACTION_1) + k(SpecialFunc.Action.BACKSPACE, 2.0f, ControlifyActions.GUI_ABSTRACT_ACTION_1) ), List.of( k(SpecialFunc.Action.TAB, 1f, null), @@ -52,10 +52,10 @@ public final class FallbackKeyboardLayout { k("k"), k("l"), k("'", "\""), - k(SpecialFunc.Action.ENTER, 2.0f, ControlifyBindings.GUI_ABSTRACT_ACTION_2) + k(SpecialFunc.Action.ENTER, 2.0f, ControlifyActions.GUI_ABSTRACT_ACTION_2) ), List.of( - k(SpecialFunc.Action.SHIFT, 2f, ControlifyBindings.GUI_ABSTRACT_ACTION_3), + k(SpecialFunc.Action.SHIFT, 2f, ControlifyActions.GUI_ABSTRACT_ACTION_3), k("z"), k("x"), k("c"), @@ -75,15 +75,18 @@ public final class FallbackKeyboardLayout { ) ); - private static Key k(SpecialFunc.Action action, float width, @Nullable InputBindingSupplier shortcutBinding) { + private static Key k(SpecialFunc.Action action, float width, @Nullable ActionAccessor shortcutBinding) { return new Key(new SpecialFunc(action), width, Optional.ofNullable(shortcutBinding)); } + private static Key k(String string) { return new Key(new StringFunc(string)); } + private static Key k(String regular, String shifted) { return new Key(new StringFunc(regular), new StringFunc(shifted)); } + private static Key k(String string, float width) { return new Key(new StringFunc(string), width); } diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyWidget.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyWidget.java similarity index 71% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/KeyWidget.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyWidget.java index 75ba270ee..4297ff092 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyWidget.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyWidget.java @@ -4,19 +4,19 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.font.BindingFontHelper; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenControllerEventListener; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.HoldRepeatHelper; +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.api.gui.keyboard.InputTarget; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.HoldRepeatHelper; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.AbstractWidget; @@ -31,7 +31,7 @@ * Represents a single key widget within a {@link KeyboardWidget}. * It handles rendering, input processing, and interaction with the keyboard layout. */ -public class KeyWidget extends AbstractWidget implements ComponentProcessor, ScreenControllerEventListener { +public class KeyWidget extends AbstractWidget implements ElementBehaviour { public static final Identifier SPRITE = CUtil.rl("keyboard/key"); public static final Identifier SPRITE_PRESSED = CUtil.rl("keyboard/key_pressed"); @@ -41,10 +41,13 @@ public class KeyWidget extends AbstractWidget implements ComponentProcessor, Scr private final Component regularLabel, shiftedLabel; private final boolean supportsRegular, supportsShifted; + private boolean mousePressed; + private boolean buttonPressed; private boolean shortcutPressed; - private final HoldRepeatHelper holdRepeatHelper; - - private boolean buttonPressed, mousePressed; + private boolean ignoreHeldPress; + private boolean newlyFocused = true; + private final HoldRepeatHelper focusedRepeatHelper = new HoldRepeatHelper(10, 2); + private final HoldRepeatHelper shortcutRepeatHelper = new HoldRepeatHelper(10, 2); private final int renderScale; private final int renderWidth, renderHeight; @@ -53,7 +56,6 @@ public KeyWidget(int x, int y, int width, int height, int renderScale, KeyboardL super(x, y, width, height, Component.literal("Key")); this.keyboard = keyboard; this.key = key; - this.holdRepeatHelper = new HoldRepeatHelper(10, 2); this.regularLabel = createLabel(key, false); this.shiftedLabel = createLabel(key, true); @@ -67,9 +69,7 @@ public KeyWidget(int x, int y, int width, int height, int renderScale, KeyboardL public void extractKeyBackground(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { this.active = this.supportsAction(); - if (!this.isFocused()) { - // the call in overrideControllerButtons won't be triggered to un-press if the key is not focused this.buttonPressed = false; } @@ -86,8 +86,6 @@ public void extractKeyBackground(GuiGraphicsExtractor graphics, int mouseX, int if (isHoveredOrFocused()) { graphics.outline(getX() - 1, getY() - 1, renderWidth + 2, renderHeight + 2, 0x80FFFFFF); - } else if (!shortcutPressed) { - this.holdRepeatHelper.reset(); } if (!this.active) { @@ -116,37 +114,43 @@ protected void extractWidgetRenderState(@NonNull GuiGraphicsExtractor graphics, } @Override - public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) { - var guiPress = ControlifyBindings.GUI_PRESS.on(controller); - - // this means if you were holding the button down and navigated to this key, - // it would not show as pressed until you release and press again - if (guiPress.justPressed()) { - this.buttonPressed = true; - } else if (guiPress.justReleased()) { - this.buttonPressed = false; - } + public void onFocusChanged(@NonNull ScreenContext screen, boolean focused) { + this.newlyFocused = focused; + this.buttonPressed = false; + this.focusedRepeatHelper.reset(); + } - // prevent the press action if the button was navigated to whilst holding the button down - // the above visual state will not be pressed, so this should not trigger either - if (this.buttonPressed && holdRepeatHelper.shouldAction(guiPress)) { + @Override + public void handleInteraction(@NonNull ScreenContext screen, @NonNull Controller controller, @NonNull Runnable next) { + if (screen.isVirtualMouseEnabled()) { + next.run(); + return; + } + Action guiPress = ControlifyActions.GUI_PRESS.on(controller); + boolean held = guiPress.latch().isLatchActive(); + if (this.newlyFocused) { + this.ignoreHeldPress = held; + this.newlyFocused = false; + } + if (!held) { + this.ignoreHeldPress = false; + } + this.buttonPressed = held && !this.ignoreHeldPress; + if (this.focusedRepeatHelper.shouldAction(this.buttonPressed)) { onPress(); - holdRepeatHelper.onNavigate(); } - - return false; + guiPress.pulse().consumeAllPulses(); + next.run(); } - @Override - public void onControllerInput(ControllerEntity controller) { - this.key.shortcutBinding().ifPresent(supplier -> { - InputBinding shortcutBinding = supplier.on(controller); - - this.shortcutPressed = shortcutBinding.digitalNow(); - - if (this.holdRepeatHelper.shouldAction(shortcutBinding)) { + public void handleShortcut(Controller controller) { + this.key.shortcutBinding().ifPresent(accessor -> { + Action shortcut = accessor.on(controller); + this.shortcutPressed = shortcut.latch().isLatchActive(); + boolean repeat = this.shortcutRepeatHelper.shouldAction(this.shortcutPressed); + boolean pressed = shortcut.pulse().consumeAllPulses(); + if (pressed || repeat) { onPress(); - this.holdRepeatHelper.onNavigate(); } }); } @@ -173,16 +177,15 @@ private void onPress() { KeyboardLayout.KeyFunction keyFunction = this.getKeyFunction(); InputTarget inputConsumer = this.keyboard.getInputTarget(); - ScreenProcessor.playClackSound(); + playDownSound(Minecraft.getInstance().getSoundManager()); boolean wasShiftAction = false; switch (keyFunction) { - case KeyboardLayout.KeyFunction.StringFunc stringKey -> - insertText(stringKey.string(), inputConsumer); + case KeyboardLayout.KeyFunction.StringFunc stringKey -> insertText(stringKey.string(), inputConsumer); case KeyboardLayout.KeyFunction.CodeFunc codeKey -> - codeKey.codes().forEach(code -> inputConsumer.acceptKeyCode(code.keycode(), code.scancode(), code.modifier())); + codeKey.codes().forEach(code -> inputConsumer.acceptKeyCode(code.keycode(), code.scancode(), code.modifier())); case KeyboardLayout.KeyFunction.SpecialFunc specialKey -> { switch (specialKey.action()) { @@ -221,8 +224,7 @@ private void onPress() { } } - case KeyboardLayout.KeyFunction.ChangeLayoutFunc func -> - this.changeLayout(func.layout()); + case KeyboardLayout.KeyFunction.ChangeLayoutFunc func -> this.changeLayout(func.layout()); } if (!wasShiftAction && this.keyboard.isShifted() && !this.keyboard.isShiftLocked()) { @@ -257,23 +259,27 @@ public KeyboardLayout.KeyFunction getKeyFunction() { } public boolean isVisuallyPressed() { - return this.buttonPressed - || this.shortcutPressed - || this.mousePressed - || this.isShiftKeyAndShifting() - || this.isShiftLockKeyAndShiftLocked(); + return this.mousePressed + || this.buttonPressed + || this.shortcutPressed + || this.isShiftKeyAndShifting() + || this.isShiftLockKeyAndShiftLocked(); } private boolean isShiftKeyAndShifting() { return this.keyboard.isShifted() && !this.keyboard.isShiftLocked() - && this.getKeyFunction() instanceof KeyboardLayout.KeyFunction.SpecialFunc(KeyboardLayout.KeyFunction.SpecialFunc.Action action) - && action == KeyboardLayout.KeyFunction.SpecialFunc.Action.SHIFT; + && this.getKeyFunction() instanceof KeyboardLayout.KeyFunction.SpecialFunc( + KeyboardLayout.KeyFunction.SpecialFunc.Action action + ) + && action == KeyboardLayout.KeyFunction.SpecialFunc.Action.SHIFT; } private boolean isShiftLockKeyAndShiftLocked() { return this.keyboard.isShiftLocked() - && this.getKeyFunction() instanceof KeyboardLayout.KeyFunction.SpecialFunc(KeyboardLayout.KeyFunction.SpecialFunc.Action action) - && action == KeyboardLayout.KeyFunction.SpecialFunc.Action.SHIFT_LOCK; + && this.getKeyFunction() instanceof KeyboardLayout.KeyFunction.SpecialFunc( + KeyboardLayout.KeyFunction.SpecialFunc.Action action + ) + && action == KeyboardLayout.KeyFunction.SpecialFunc.Action.SHIFT_LOCK; } private boolean supportsAction(boolean shifted) { @@ -284,13 +290,12 @@ private boolean supportsAction(boolean shifted) { return switch (this.getKey().getFunction(shifted)) { case KeyboardLayout.KeyFunction.StringFunc ignored -> supportsCharInput; case KeyboardLayout.KeyFunction.CodeFunc ignored -> supportsKeyCodeInput; - case KeyboardLayout.KeyFunction.SpecialFunc specialFunc -> - switch (specialFunc.action()) { - case ENTER, BACKSPACE, LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW -> supportsKeyCodeInput; - case TAB, PASTE -> supportsCharInput; - case COPY_ALL -> supportsCopying; - case SHIFT, SHIFT_LOCK, PREVIOUS_LAYOUT -> true; - }; + case KeyboardLayout.KeyFunction.SpecialFunc specialFunc -> switch (specialFunc.action()) { + case ENTER, BACKSPACE, LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW -> supportsKeyCodeInput; + case TAB, PASTE -> supportsCharInput; + case COPY_ALL -> supportsCopying; + case SHIFT, SHIFT_LOCK, PREVIOUS_LAYOUT -> true; + }; case KeyboardLayout.KeyFunction.ChangeLayoutFunc ignored -> true; }; } @@ -306,7 +311,6 @@ private void changeLayout(Identifier layoutId) { @Override protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { - } private void doScaledExtraction(GuiGraphicsExtractor graphics, Runnable runnable) { @@ -343,7 +347,7 @@ private static Component createLabel(KeyboardLayout.Key key, boolean shift) { KeyboardLayout.KeyFunction keyFunction = key.getFunction(shift); return key.shortcutBinding() - .map(b -> BindingFontHelper.binding(b.bindId())) + .map(b -> ActionGlyphs.action(b.actionId())) .map(glyph -> Component.empty() .append(glyph) .append(" ") diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayout.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayout.java similarity index 87% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayout.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayout.java index 2173a0a46..f4d0f191a 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayout.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayout.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; import com.mojang.datafixers.util.Either; import com.mojang.datafixers.util.Pair; @@ -12,8 +12,8 @@ import com.mojang.serialization.DataResult; import com.mojang.serialization.DynamicOps; import com.mojang.serialization.codecs.RecordCodecBuilder; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.utils.codec.CExtraCodecs; +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.impl.utils.codec.CExtraCodecs; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.ComponentSerialization; import net.minecraft.resources.Identifier; @@ -32,8 +32,9 @@ * A keyboard layout consists of keys within rows, it is a column-aligned layout, * meaning keys cannot span multiple columns. * A keyboard layout has a single shift-layer, defined by the {@link Key}. + * * @param width the unit width of the keyboard layout. all rows must sum to this width. - * @param keys the rows of keys in the keyboard layout, row-major order. + * @param keys the rows of keys in the keyboard layout, row-major order. */ public record KeyboardLayout(float width, List> keys) { public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( @@ -44,9 +45,9 @@ public record KeyboardLayout(float width, List> keys) { .fieldOf("keys").forGetter(KeyboardLayout::keys) ).apply(instance, KeyboardLayout::new)).validate( layout -> - validateRowWidths(layout) - ? DataResult.success(layout) - : DataResult.error(() -> "Row widths do not match the specified row width: " + layout.width()) + validateRowWidths(layout) + ? DataResult.success(layout) + : DataResult.error(() -> "Row widths do not match the specified row width: " + layout.width()) ); public static boolean validateRowWidths(KeyboardLayout layout) { @@ -78,13 +79,14 @@ public static KeyboardLayout of(float width, List... keys) { *

* Each key function provides the display name of the key with {@link KeyFunction#displayName()}. * - * @param regular the regular key function, which is used when the shift is not enabled. - * @param shifted the shifted key function, which is used when the shift is enabled - * @param width the unit width of the key, which is multiplied by the keyboard width to determine the actual pixel width of the key + * @param regular the regular key function, which is used when the shift is not enabled. + * @param shifted the shifted key function, which is used when the shift is enabled + * @param width the unit width of the key, which is multiplied by the keyboard width to determine the actual pixel width of the key * @param shortcutBinding an optional shortcut binding for the key, used to display a shortcut in the UI - * @param identifier an optional identifier for the key, used when changing layouts to focus a specific key with a matching identifier + * @param identifier an optional identifier for the key, used when changing layouts to focus a specific key with a matching identifier */ - public record Key(KeyFunction regular, KeyFunction shifted, float width, Optional shortcutBinding, @Nullable String identifier) { + public record Key(KeyFunction regular, KeyFunction shifted, float width, Optional shortcutBinding, + @Nullable String identifier) { private static final Codec WIDTH_CODEC = Codec.floatRange(0.1f, Float.MAX_VALUE); private static final Codec PAIR_CODEC = Codec.withAlternative( @@ -92,7 +94,7 @@ public record Key(KeyFunction regular, KeyFunction shifted, float width, Optiona KeyFunction.CODEC.fieldOf("regular").forGetter(Key::regular), KeyFunction.CODEC.optionalFieldOf("shifted").forGetter(k -> Optional.of(k.shifted)), WIDTH_CODEC.optionalFieldOf("width", 1f).forGetter(Key::width), - InputBindingSupplier.CODEC.optionalFieldOf("shortcut").forGetter(Key::shortcutBinding), + ActionAccessor.CODEC.optionalFieldOf("shortcut").forGetter(Key::shortcutBinding), Codec.STRING.optionalFieldOf("identifier").forGetter(k -> Optional.ofNullable(k.identifier)) ).apply(instance, Key::fromCodec)), CExtraCodecs.arrayPair(KeyFunction.CODEC, Key::regular, Key::shifted, Key::new) @@ -112,7 +114,7 @@ public Key(KeyFunction keyFunction, float width) { this(keyFunction, width, Optional.empty()); } - public Key(KeyFunction keyFunction, float width, Optional shortcut) { + public Key(KeyFunction keyFunction, float width, Optional shortcut) { this(keyFunction, keyFunction.createShifted(), width, shortcut, null); } @@ -120,7 +122,7 @@ public Key(KeyFunction regular, KeyFunction shifted) { this(regular, shifted, 1f, Optional.empty(), null); } - private static Key fromCodec(KeyFunction regular, Optional shifted, float width, Optional shortcut, Optional identifier) { + private static Key fromCodec(KeyFunction regular, Optional shifted, float width, Optional shortcut, Optional identifier) { return new Key( regular, shifted.orElseGet(regular::createShifted), @@ -149,6 +151,7 @@ public sealed interface KeyFunction { /** * Returns the display name of the key function. * Used to display the key in the UI. + * * @return the display name of the key function */ Component displayName(); @@ -156,6 +159,7 @@ public sealed interface KeyFunction { /** * Creates a shifted version of this key function. * May return itself if the function does not support shifting. + * * @return the shifted key function */ default KeyFunction createShifted() { @@ -169,7 +173,8 @@ public DataResult encode(KeyFunction input, DynamicOps ops, T prefix) case StringFunc stringKey -> StringFunc.CODEC.encode(stringKey, ops, prefix); case CodeFunc codeKey -> CodeFunc.CODEC.encode(codeKey, ops, prefix); case SpecialFunc specialKey -> SpecialFunc.CODEC.encode(specialKey, ops, prefix); - case ChangeLayoutFunc changeLayoutKey -> ChangeLayoutFunc.CODEC.encode(changeLayoutKey, ops, prefix); + case ChangeLayoutFunc changeLayoutKey -> + ChangeLayoutFunc.CODEC.encode(changeLayoutKey, ops, prefix); }; } @@ -189,7 +194,7 @@ public DataResult> decode(DynamicOps ops, T input) { *

* This function supports {@link #createShifted()} which uses {@link String#toUpperCase()} to create a shifted version of the string. * - * @param string the string to insert when the key is pressed + * @param string the string to insert when the key is pressed * @param manualDisplayName an optional manual display name for the key, if not provided, the string itself is used when returning {@link #displayName()} */ record StringFunc(String string, @Nullable Component manualDisplayName) implements KeyFunction { @@ -225,7 +230,7 @@ public KeyFunction createShifted() { *

* This function does not support {@link #createShifted()} as key codes cannot be automatically upper-cased. * - * @param codes the list of key codes to insert when the key is pressed + * @param codes the list of key codes to insert when the key is pressed * @param displayName the display name of the key function, used to display the key in the UI */ record CodeFunc(List codes, Component displayName) implements KeyFunction { @@ -237,7 +242,7 @@ record CodeFunc(List codes, Component displayName) implements KeyFuncti /** * A key code is a combination of a keycode, scancode, and modifier. * - * @param keycode is the logical key code, platform agnostic. use {@link com.mojang.blaze3d.platform.InputConstants} to get the key code. + * @param keycode is the logical key code, platform agnostic. use {@link com.mojang.blaze3d.platform.InputConstants} to get the key code. * @param scancode is the physical key code, platform specific. usually leaving blank is fine since no one ever looks at it. * @param modifier is the modifier bitset, which can be used to specify additional key modifiers like shift, ctrl, alt, etc. * use {@link org.lwjgl.glfw.GLFW#GLFW_MOD_SHIFT} etc @@ -318,7 +323,7 @@ public Component displayName() { *

* This function does not support {@link #createShifted()} as layouts cannot be automatically upper-cased. * - * @param layout the layout id to switch to when the key is pressed. + * @param layout the layout id to switch to when the key is pressed. * @param displayName the display name of the key function, used to display the key in the UI */ record ChangeLayoutFunc(Identifier layout, Component displayName) implements KeyFunction { diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayoutManager.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayoutManager.java similarity index 82% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayoutManager.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayoutManager.java index 5c4485523..6d7be86ef 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayoutManager.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayoutManager.java @@ -4,33 +4,35 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.log.ControlifyLogger; -import net.minecraft.util.Util; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; import net.minecraft.client.Minecraft; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.Resource; import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.Util; import java.io.BufferedReader; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; +import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Manages and loads all keyboard layouts from resource packs. */ public class KeyboardLayoutManager implements SimpleControlifyReloadListener { - private static final String PREFIX = "keyboard_layout"; + private static final String PREFIX = "controller/keyboard_layout"; private static final ControlifyLogger LOGGER = CUtil.LOGGER.createSubLogger("KeyboardLayoutManager"); private Map layouts = Map.of(); @@ -46,14 +48,14 @@ public CompletableFuture load(ResourceManager manager, Executor ex .toList(); var map = Util.sequence(futures) - .thenApply(listOfEntries -> listOfEntries.stream() + .thenApply(listOfEntries -> listOfEntries.stream().flatMap(Function.identity()) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a))); return map.thenApply(Preparations::new); }); } - private CompletableFuture> loadLayout( + private CompletableFuture>> loadLayout( Identifier file, Resource resource, Executor executor ) { return CompletableFuture.supplyAsync(() -> { @@ -64,9 +66,10 @@ private CompletableFuture> loadLayo KeyboardLayout layout = KeyboardLayout.CODEC.parse(JsonOps.INSTANCE, json) .getOrThrow(reason -> new RuntimeException("Failed to parse keyboard layout " + key + ": " + reason)); - return Map.entry(key, layout); + return Stream.of(Map.entry(key, layout)); } catch (Exception e) { - throw new RuntimeException("Failed to read keyboard layout " + key + ": " + e.getMessage(), e); + LOGGER.error("Failed to read keyboard layout {}", e, key); + return Stream.empty(); } }, executor); } @@ -85,7 +88,7 @@ public KeyboardLayoutWithId getLayout(Identifier layoutId, String languageCode) return Optional.ofNullable(this.layouts.get(key)) .or(() -> Optional.ofNullable(this.layouts.get(key.withDefaultLanguage()))) .map(layout -> new KeyboardLayoutWithId(layout, layoutId)) - .orElse(KeyboardLayouts.fallback()); + .orElse(new KeyboardLayoutWithId(FallbackKeyboardLayout.QWERTY, FallbackKeyboardLayout.ID)); } public KeyboardLayoutWithId getLayout(Identifier layout) { @@ -105,8 +108,8 @@ private static Identifier keyToFile(KeyboardLayoutKey key) { private static KeyboardLayoutKey fileToKey(Identifier file) { try { var components = file.getPath().split("/"); - var layoutPath = components[1]; - var languageCodeWithExt = components[2]; + var layoutPath = components[2]; + var languageCodeWithExt = components[3]; var languageCode = languageCodeWithExt.substring(0, languageCodeWithExt.lastIndexOf('.')); var layoutId = file.withPath(layoutPath); @@ -121,7 +124,8 @@ private static Map listMatchingResources(ResourceManager r return resourceManager.listResources(PREFIX, path -> path.getPath().endsWith(".json")); } - public record Preparations(Map layouts) {} + public record Preparations(Map layouts) { + } private record KeyboardLayoutKey(String languageCode, Identifier layoutId) { public static final String DEFAULT_LANGUAGE = "en_us"; diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayoutWithId.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayoutWithId.java similarity index 80% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayoutWithId.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayoutWithId.java index 3ddb18786..e27dd8647 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardLayoutWithId.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardLayoutWithId.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; import net.minecraft.resources.Identifier; @@ -12,8 +12,9 @@ * Represents a keyboard layout with an associated ID. * Layouts themselves do not have IDs, but this record * couples a layout with its resource pack ID. + * * @param layout the keyboard layout - * @param id the resource location ID of the layout + * @param id the resource location ID of the layout */ public record KeyboardLayoutWithId(KeyboardLayout layout, Identifier id) { } diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardOverlayScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardOverlayScreen.java similarity index 63% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardOverlayScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardOverlayScreen.java index dfdde2771..dc1753588 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardOverlayScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardOverlayScreen.java @@ -4,20 +4,21 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.api.gui.keyboard.InputTarget; +import dev.isxander.controlify.api.gui.keyboard.KeyboardPositioner; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import dev.isxander.controlify.mixins.feature.screenop.GuiMixin; -import dev.isxander.controlify.utils.MinecraftUtil; import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.gui.navigation.ScreenAxis; import net.minecraft.client.gui.navigation.ScreenRectangle; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.resources.Identifier; import org.jspecify.annotations.NonNull; -import java.util.function.Supplier; /** * A screen with a single widget: the keyboard widget. @@ -29,12 +30,12 @@ * instead of adding and removing widgets, where focus to the keyboard could * be lost without the keyboard closing. * - * @see GuiMixin#preventRemovingOldScreen(Screen, Screen) - * this mixin prevents calling removed() on the underlying screen when this overlay is presented, since it will be restored + * The GUI mixin prevents calling removed() on the underlying screen when this + * overlay is presented, since the underlying screen will be restored. */ public class KeyboardOverlayScreen extends Screen { private final Screen backgroundScreen; - private final KeyboardLayoutWithId initialKeyboardLayout; + private final Identifier initialKeyboardLayout; private final InputTarget inputTarget; private final KeyboardPositioner keyboardPositioner; @@ -42,7 +43,7 @@ public class KeyboardOverlayScreen extends Screen { public KeyboardOverlayScreen( Screen backgroundScreen, - KeyboardLayoutWithId initialKeyboardLayout, + Identifier initialKeyboardLayout, InputTarget inputTarget, KeyboardPositioner keyboardPositioner ) { @@ -57,7 +58,7 @@ public KeyboardOverlayScreen( protected void init() { KeyboardLayoutWithId layout = this.keyboardWidget != null ? Controlify.instance().keyboardLayoutManager().getLayout(this.keyboardWidget.getCurrentLayoutId()) - : this.initialKeyboardLayout; + : Controlify.instance().keyboardLayoutManager().getLayout(this.initialKeyboardLayout); ScreenRectangle keyboardRect = this.keyboardPositioner.positionKeyboard(this.width, this.height); @@ -125,14 +126,14 @@ public void onClose() { // so the screen is not reinitialised MinecraftUtil.forceSetScreen(this.backgroundScreen); - Controlify.instance().virtualMouseHandler().onScreenChanged(); + dev.isxander.controlify.impl.screenop.ScreenRuntime.onScreenChanged(); } /** * An input target that captures {@link InputConstants#KEY_RETURN} and {@link InputConstants#KEY_ESCAPE} * key presses to close the keyboard overlay screen. */ - private class WrappedInputTarget extends InputTarget.Delegated { + private class WrappedInputTarget extends DelegatingInputTarget { public WrappedInputTarget(InputTarget target) { super(target); } @@ -149,53 +150,4 @@ public boolean acceptKeyCode(int keycode, int scancode, int modifiers) { } } - @FunctionalInterface - public interface KeyboardPositioner { - ScreenRectangle positionKeyboard(int screenWidth, int screenHeight); - } - - public static KeyboardPositioner aboveOrBelowWidgetPositioner(int desiredKeyboardWidth, int desiredKeyboardHeight, int padding, Supplier widgetRectSupplier) { - return (screenWidth, screenHeight) -> { - ScreenRectangle widgetRect = widgetRectSupplier.get(); - - int keyboardWidth = Math.min(desiredKeyboardWidth, screenWidth); - int keyboardLeft = Math.clamp( - widgetRect.getCenterInAxis(ScreenAxis.HORIZONTAL) - keyboardWidth / 2, - 0, screenWidth - keyboardWidth - ); - - int spaceBelow = screenHeight - widgetRect.bottom() - padding; - int spaceAbove = widgetRect.top() - padding; - - // Determine the best vertical position for the keyboard. - // 1. Prefer placing the keyboard below the widget, if it fits. - // 2. If there's not enough space below, check space above. - // 3. If neither fits fully, pick the side with more space and shrink the height. - int keyboardHeight; - boolean above; - if (spaceBelow >= desiredKeyboardHeight) { - keyboardHeight = desiredKeyboardHeight; - above = false; - } else if (spaceAbove >= desiredKeyboardHeight) { - keyboardHeight = desiredKeyboardHeight; - above = true; - } else if (spaceBelow >= spaceAbove) { - keyboardHeight = spaceBelow; - above = false; - } else { - keyboardHeight = spaceAbove; - above = true; - } - int keyboardTop = above - ? widgetRect.top() - keyboardHeight - padding - : widgetRect.bottom() + padding; - - return new ScreenRectangle( - keyboardLeft, - keyboardTop, - keyboardWidth, - keyboardHeight - ); - }; - } } diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardWidget.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardWidget.java similarity index 74% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardWidget.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardWidget.java index cc28107fd..ab082886c 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/KeyboardWidget.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/KeyboardWidget.java @@ -4,15 +4,17 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; import com.google.common.collect.ImmutableList; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.utils.HoldRepeatHelper; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.keyboard.InputTarget; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.utils.HoldRepeatHelper; import net.minecraft.client.gui.ComponentPath; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.AbstractWidget; @@ -38,7 +40,7 @@ * arranging the keys based on a {@link KeyboardLayout} as well as coordinating inputs and global * state between keys. */ -public class KeyboardWidget extends AbstractWidget implements ContainerEventHandler, ComponentProcessor { +public class KeyboardWidget extends AbstractWidget implements ContainerEventHandler, ElementBehaviour { private Identifier currentLayout; private @Nullable Identifier previousLayout; @@ -51,8 +53,8 @@ public class KeyboardWidget extends AbstractWidget implements ContainerEventHand private @Nullable KeyWidget focused; private boolean isDragging; - private final HoldRepeatHelper fwdCursorHelper = new HoldRepeatHelper(10, 2); - private final HoldRepeatHelper bwdCursorHelper = new HoldRepeatHelper(10, 2); + private final HoldRepeatHelper forwardCursorRepeat = new HoldRepeatHelper(10, 2); + private final HoldRepeatHelper backwardCursorRepeat = new HoldRepeatHelper(10, 2); public KeyboardWidget(int x, int y, int width, int height, KeyboardLayoutWithId layout, InputTarget inputConsumer) { super(x, y, width, height, Component.literal("On-Screen Keyboard")); @@ -75,19 +77,19 @@ public void updateLayout(KeyboardLayoutWithId layout, @Nullable String identifie this.arrangeKeys(layout.layout()); - findKey( - identifierToFocus != null, + setFocused(findKey( + identifierToFocus == null, k -> Objects.equals(k.getKey().identifier(), identifierToFocus) ).or(() -> findKey( - oldLayoutChangerToFocus != null, + oldLayoutChangerToFocus == null, k -> { boolean isOldLayout = k.getKeyFunction() instanceof KeyboardLayout.KeyFunction.ChangeLayoutFunc changeLayoutKey - && changeLayoutKey.layout().equals(oldLayoutChangerToFocus); + && changeLayoutKey.layout().equals(oldLayoutChangerToFocus); boolean isPrevLayout = k.getKeyFunction() instanceof KeyboardLayout.KeyFunction.SpecialFunc specialFunc - && specialFunc.action() == KeyboardLayout.KeyFunction.SpecialFunc.Action.PREVIOUS_LAYOUT; + && specialFunc.action() == KeyboardLayout.KeyFunction.SpecialFunc.Action.PREVIOUS_LAYOUT; return isOldLayout || isPrevLayout; }) - ); + ).orElse(this.keys.isEmpty() ? null : this.keys.getFirst())); } private void arrangeKeys(KeyboardLayout layout) { @@ -147,45 +149,41 @@ protected void extractWidgetRenderState(@NonNull GuiGraphicsExtractor graphics, } @Override - public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) { + public void handleNavigation(@NonNull ScreenContext screen, @NonNull Controller controller, @NonNull Runnable next) { InputTarget inputTarget = this.getInputTarget(); - var settings = controller.settings().generic; - - if (inputTarget.supportsCursorMovement()) { - if (this.fwdCursorHelper.shouldAction(ControlifyBindings.GUI_NEXT_TAB.on(controller))) { - if (inputTarget.moveCursor(1)) { - ScreenProcessor.playFocusChangeSound(); - this.fwdCursorHelper.onNavigate(); - this.bwdCursorHelper.reset(); - - if (settings.keyboard.hintCursor && settings.guide.showScreenGuides) { - settings.keyboard.hintCursor = false; - Controlify.instance().config().saveSafely(); - } - - return true; - } + var settings = ((ControllerImpl) controller).settings().generic; + boolean supportsCursor = inputTarget.supportsCursorMovement(); + boolean forward = this.forwardCursorRepeat.shouldAction(supportsCursor + && ControlifyActions.GUI_NEXT_TAB.on(controller).latch().isLatchActive()); + boolean backward = this.backwardCursorRepeat.shouldAction(supportsCursor + && ControlifyActions.GUI_PREV_TAB.on(controller).latch().isLatchActive()); + boolean movedCursor = false; + if (supportsCursor) { + ControlifyActions.GUI_NEXT_TAB.on(controller).pulse().consumeAllPulses(); + ControlifyActions.GUI_PREV_TAB.on(controller).pulse().consumeAllPulses(); + if (forward) { + movedCursor = inputTarget.moveCursor(1); + } else if (backward) { + movedCursor = inputTarget.moveCursor(-1); } - if (this.bwdCursorHelper.shouldAction(ControlifyBindings.GUI_PREV_TAB.on(controller))) { - if (inputTarget.moveCursor(-1)) { - ScreenProcessor.playFocusChangeSound(); - this.bwdCursorHelper.onNavigate(); - this.fwdCursorHelper.reset(); - - if (settings.keyboard.hintCursor && settings.guide.showScreenGuides) { - settings.keyboard.hintCursor = false; - Controlify.instance().config().saveSafely(); - } - - return true; - } + } + if (movedCursor) { + screen.playFocusChangeSound(); + if (settings.keyboard.hintCursor && settings.guide.showScreenGuides) { + settings.keyboard.hintCursor = false; + Controlify.instance().config().saveSafely(); } } - this.keys.forEach(k -> k.onControllerInput(controller)); + next.run(); + } - // prevent default button handling for gui press which would send enter which is most likely submit - return ControlifyBindings.GUI_PRESS.on(controller).guiPressed().get(); + @Override + public void handleInteraction(@NonNull ScreenContext screen, @NonNull Controller controller, @NonNull Runnable next) { + this.keys.forEach(key -> key.handleShortcut(controller)); + // The focused key handles activation; the screen must not also send Enter. + ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses(); + next.run(); } public void setShifted(boolean shifted) { @@ -237,7 +235,6 @@ private Optional findKey(boolean skipSearch, Predicate pre @Override protected void updateWidgetNarration(NarrationElementOutput narrationElementOutput) { - } @Override @@ -290,10 +287,12 @@ public ComponentPath nextFocusPath(@NonNull FocusNavigationEvent event) { public boolean mouseClicked(@NonNull MouseButtonEvent mouseButtonEvent, boolean doubleClick) { return ContainerEventHandler.super.mouseClicked(mouseButtonEvent, doubleClick); } + @Override public boolean mouseReleased(@NonNull MouseButtonEvent mouseButtonEvent) { return ContainerEventHandler.super.mouseReleased(mouseButtonEvent); } + @Override public boolean mouseDragged(@NonNull MouseButtonEvent mouseButtonEvent, double dx, double dy) { return ContainerEventHandler.super.mouseDragged(mouseButtonEvent, dx, dy); diff --git a/src/main/java/dev/isxander/controlify/screenop/keyboard/MixinInputTarget.java b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/MixinInputTarget.java similarity index 93% rename from src/main/java/dev/isxander/controlify/screenop/keyboard/MixinInputTarget.java rename to src/main/java/dev/isxander/controlify/impl/gui/keyboard/MixinInputTarget.java index 976db419a..b35284f0b 100644 --- a/src/main/java/dev/isxander/controlify/screenop/keyboard/MixinInputTarget.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/keyboard/MixinInputTarget.java @@ -4,7 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.keyboard; +package dev.isxander.controlify.impl.gui.keyboard; + +import dev.isxander.controlify.api.gui.keyboard.InputTarget; /** * A wrapper to {@link InputTarget} to add alternative method names, diff --git a/src/main/java/dev/isxander/controlify/gui/layout/AbstractLayoutComponent.java b/src/main/java/dev/isxander/controlify/impl/gui/layout/AbstractLayoutComponent.java similarity index 95% rename from src/main/java/dev/isxander/controlify/gui/layout/AbstractLayoutComponent.java rename to src/main/java/dev/isxander/controlify/impl/gui/layout/AbstractLayoutComponent.java index c340d8821..48b6d5446 100644 --- a/src/main/java/dev/isxander/controlify/gui/layout/AbstractLayoutComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/layout/AbstractLayoutComponent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.layout; +package dev.isxander.controlify.impl.gui.layout; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/dev/isxander/controlify/gui/layout/AnchorPoint.java b/src/main/java/dev/isxander/controlify/impl/gui/layout/AnchorPoint.java similarity index 92% rename from src/main/java/dev/isxander/controlify/gui/layout/AnchorPoint.java rename to src/main/java/dev/isxander/controlify/impl/gui/layout/AnchorPoint.java index 236c1be60..91d5750a6 100644 --- a/src/main/java/dev/isxander/controlify/gui/layout/AnchorPoint.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/layout/AnchorPoint.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.layout; +package dev.isxander.controlify.impl.gui.layout; import org.joml.Vector2i; diff --git a/src/main/java/dev/isxander/controlify/gui/layout/ColumnLayoutComponent.java b/src/main/java/dev/isxander/controlify/impl/gui/layout/ColumnLayoutComponent.java similarity index 98% rename from src/main/java/dev/isxander/controlify/gui/layout/ColumnLayoutComponent.java rename to src/main/java/dev/isxander/controlify/impl/gui/layout/ColumnLayoutComponent.java index 9df464f94..da856ba3f 100644 --- a/src/main/java/dev/isxander/controlify/gui/layout/ColumnLayoutComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/layout/ColumnLayoutComponent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.layout; +package dev.isxander.controlify.impl.gui.layout; import net.minecraft.client.gui.GuiGraphicsExtractor; import org.apache.commons.lang3.Validate; diff --git a/src/main/java/dev/isxander/controlify/gui/layout/PositionedComponent.java b/src/main/java/dev/isxander/controlify/impl/gui/layout/PositionedComponent.java similarity index 97% rename from src/main/java/dev/isxander/controlify/gui/layout/PositionedComponent.java rename to src/main/java/dev/isxander/controlify/impl/gui/layout/PositionedComponent.java index 84eb8f482..533f925a6 100644 --- a/src/main/java/dev/isxander/controlify/gui/layout/PositionedComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/layout/PositionedComponent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.layout; +package dev.isxander.controlify.impl.gui.layout; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Renderable; diff --git a/src/main/java/dev/isxander/controlify/gui/layout/RenderComponent.java b/src/main/java/dev/isxander/controlify/impl/gui/layout/RenderComponent.java similarity index 89% rename from src/main/java/dev/isxander/controlify/gui/layout/RenderComponent.java rename to src/main/java/dev/isxander/controlify/impl/gui/layout/RenderComponent.java index 9bb67b2db..1afb27ab6 100644 --- a/src/main/java/dev/isxander/controlify/gui/layout/RenderComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/layout/RenderComponent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.layout; +package dev.isxander.controlify.impl.gui.layout; import net.minecraft.client.gui.GuiGraphicsExtractor; import org.joml.Vector2ic; diff --git a/src/main/java/dev/isxander/controlify/gui/layout/RowLayoutComponent.java b/src/main/java/dev/isxander/controlify/impl/gui/layout/RowLayoutComponent.java similarity index 98% rename from src/main/java/dev/isxander/controlify/gui/layout/RowLayoutComponent.java rename to src/main/java/dev/isxander/controlify/impl/gui/layout/RowLayoutComponent.java index 691455dc3..69f1e282c 100644 --- a/src/main/java/dev/isxander/controlify/gui/layout/RowLayoutComponent.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/layout/RowLayoutComponent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.layout; +package dev.isxander.controlify.impl.gui.layout; import net.minecraft.client.gui.GuiGraphicsExtractor; import org.apache.commons.lang3.Validate; diff --git a/src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIcon.java b/src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIcon.java new file mode 100644 index 000000000..c5b2722d0 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIcon.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.gui.radial; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentSerialization; +import net.minecraft.resources.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +public record RadialIcon(Content content, @Nullable Component overlay) { + public static final RadialIcon EMPTY = new RadialIcon(new Empty(), null); + + public static final Codec CODEC = Definition.CODEC.flatXmap( + Definition::toRadialIcon, + RadialIcon::toDefinition + ); + + public static RadialIcon model(Identifier model, boolean enchantGlint) { + return new RadialIcon(new Model(model, enchantGlint), null); + } + + public static RadialIcon model(Identifier model) { + return model(model, false); + } + + public static RadialIcon texture(Identifier texture) { + return new RadialIcon(new Texture(texture), null); + } + + public RadialIcon withOverlay(Component overlay) { + return new RadialIcon(content, overlay); + } + + private static DataResult toDefinition(RadialIcon icon) { + return switch (icon.content()) { + case Model model -> DataResult.success(new Definition( + Optional.of(model), + Optional.empty(), + Optional.ofNullable(icon.overlay()) + )); + case Texture texture -> DataResult.success(new Definition( + Optional.empty(), + Optional.of(texture), + Optional.ofNullable(icon.overlay()) + )); + default -> DataResult.error(() -> "Only model and texture radial icons can be serialized"); + }; + } + + public sealed interface Content permits Empty, Model, Texture { + } + + public record Empty() implements Content { + } + + public record Model(Identifier id, boolean enchantGlint) implements Content { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("id").forGetter(Model::id), + Codec.BOOL.optionalFieldOf("enchant_glint", false).forGetter(Model::enchantGlint) + ).apply(instance, Model::new)) + .withAlternative(Identifier.CODEC.xmap(id -> new Model(id, false), Model::id)); + } + + public record Texture(Identifier id) implements Content { + public static final Codec CODEC = Identifier.CODEC.xmap(Texture::new, Texture::id); + } + + private record Definition(Optional model, Optional texture, Optional overlay) { + private static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Model.CODEC.optionalFieldOf("model").forGetter(Definition::model), + Texture.CODEC.optionalFieldOf("texture").forGetter(Definition::texture), + ComponentSerialization.CODEC.optionalFieldOf("overlay").forGetter(Definition::overlay) + ).apply(instance, Definition::new)) + .validate(def -> { + if (def.model.isPresent() == def.texture.isPresent()) { + return DataResult.error(() -> "Radial icon must define exactly one of 'model' or 'texture'"); + } + return DataResult.success(def); + }); + + private DataResult toRadialIcon() { + if (this.model.isPresent() == this.texture.isPresent()) { + return DataResult.error(() -> "Radial icon must define exactly one of 'model' or 'texture'"); + } + + Content content = this.model.isPresent() + ? this.model.get() + : this.texture.get(); + + return DataResult.success(new RadialIcon(content, this.overlay.orElse(null))); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/bindings/RadialIconExtractor.java b/src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIconExtractor.java similarity index 75% rename from src/main/java/dev/isxander/controlify/bindings/RadialIconExtractor.java rename to src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIconExtractor.java index 5c47b22bb..a7d349cd1 100644 --- a/src/main/java/dev/isxander/controlify/bindings/RadialIconExtractor.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIconExtractor.java @@ -4,9 +4,8 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.bindings; +package dev.isxander.controlify.impl.gui.radial; -import dev.isxander.controlify.api.bind.RadialIcon; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.renderer.RenderPipelines; @@ -22,7 +21,7 @@ public final class RadialIconExtractor { public static void extract(GuiGraphicsExtractor graphics, RadialIcon icon, int x, int y) { switch (icon.content()) { case RadialIcon.Empty _ -> {} - case RadialIcon.Model(Identifier model) -> extractModel(graphics, model, x, y); + case RadialIcon.Model(Identifier model, boolean enchantGlint) -> extractModel(graphics, model, enchantGlint, x, y); case RadialIcon.Texture(Identifier texture) -> extractTexture(graphics, texture, x, y); } @@ -31,15 +30,16 @@ public static void extract(GuiGraphicsExtractor graphics, RadialIcon icon, int x } } - private static void extractModel(GuiGraphicsExtractor graphics, Identifier model, int x, int y) { + private static void extractModel(GuiGraphicsExtractor graphics, Identifier model, boolean enchantGlint, int x, int y) { ItemStack stack = new ItemStack(Holder.direct( Items.STICK, DataComponentMap.builder() - .set(DataComponents.MAX_STACK_SIZE, 1) - .set(DataComponents.MAX_DAMAGE, 2) - .set(DataComponents.DAMAGE, 0) - .set(DataComponents.ITEM_MODEL, model) - .build() + .set(DataComponents.MAX_STACK_SIZE, 1) + .set(DataComponents.MAX_DAMAGE, 2) + .set(DataComponents.DAMAGE, 0) + .set(DataComponents.ITEM_MODEL, model) + .set(DataComponents.ENCHANTMENT_GLINT_OVERRIDE, enchantGlint) + .build() )); graphics.item(stack, x, y); diff --git a/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java b/src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIconManager.java similarity index 66% rename from src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java rename to src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIconManager.java index 62537bac9..be06d4556 100644 --- a/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/radial/RadialIconManager.java @@ -4,16 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.bindings; +package dev.isxander.controlify.impl.gui.radial; import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.mojang.serialization.Codec; import com.mojang.serialization.JsonOps; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.bind.RadialIcon; -import dev.isxander.controlify.platform.client.resource.SimpleControlifyReloadListener; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.Resource; import net.minecraft.server.packs.resources.ResourceManager; @@ -25,25 +24,22 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; -public final class RadialIconManager implements SimpleControlifyReloadListener> { - public static final RadialIconManager INSTANCE = new RadialIconManager(); +public class RadialIconManager implements SimpleControlifyReloadListener> { public static final Identifier EMPTY = CUtil.rl("empty"); - private static final Identifier DEFINITIONS = CUtil.rl("radial_icons.json"); + private static final Identifier DEFINITIONS = CUtil.rl("controller/radial_icons.json"); private static final Codec> CODEC = Codec.unboundedMap(Identifier.CODEC, RadialIcon.CODEC); private static final RadialIcon FALLBACK = RadialIcon.model(Identifier.withDefaultNamespace("book")); - private volatile Map iconsByBinding = Map.of(); + private volatile Map iconsByActionId = Map.of(); - public boolean isRadialCandidate(InputBinding binding) { - // noinspection removal - return iconsByBinding.containsKey(binding.id()) - || binding.id().getNamespace().equals(ControlifyBindings.MODDED_BIND_NAMESPACE) - || binding.isRadialCandidate(); + public boolean isRadialCandidate(Identifier actionId) { + return this.iconsByActionId.containsKey(actionId) + || actionId.getNamespace().equals(ControlifyActions.MODDED_ACTION_NAMESPACE); } - public RadialIcon getIcon(InputBinding binding) { - return iconsByBinding.getOrDefault(binding.id(), FALLBACK); + public RadialIcon getIcon(Identifier actionId) { + return iconsByActionId.getOrDefault(actionId, FALLBACK); } @Override @@ -75,14 +71,11 @@ public CompletableFuture> load(ResourceManager manag @Override public CompletableFuture apply(Map data, ResourceManager manager, Executor executor) { - return CompletableFuture.runAsync(() -> iconsByBinding = data, executor); + return CompletableFuture.runAsync(() -> iconsByActionId = data, executor); } @Override public Identifier getReloadId() { return CUtil.rl("radial_icons"); } - - private RadialIconManager() { - } } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/AskToMapControllerScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/AskToMapControllerScreen.java similarity index 72% rename from src/main/java/dev/isxander/controlify/gui/screen/AskToMapControllerScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/AskToMapControllerScreen.java index dbeaa46d4..4745703be 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/AskToMapControllerScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/AskToMapControllerScreen.java @@ -4,20 +4,20 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.gui.screens.ConfirmScreen; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; public class AskToMapControllerScreen extends ConfirmScreen { - public AskToMapControllerScreen(ControllerEntity controller, Screen lastScreen) { + public AskToMapControllerScreen(ControllerImpl controller, Screen lastScreen) { super( (confirmed) -> { if (confirmed) { - MinecraftUtil.setScreen(ControllerMappingMakerScreen.createGamepadMapping(controller.input().orElseThrow(), lastScreen)); + MinecraftUtil.setScreen(ControllerMappingMakerScreen.createGamepadMapping(controller, lastScreen)); } else { MinecraftUtil.setScreen(lastScreen); } diff --git a/src/main/java/dev/isxander/controlify/impl/gui/screen/BindConsumerScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/BindConsumerScreen.java new file mode 100644 index 000000000..c17a6af3e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/BindConsumerScreen.java @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.gui.screen; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.gui.controllers.BindController; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.impl.screenop.ScreenRuntime; +import dev.isxander.yacl3.api.utils.Dimension; +import net.minecraft.client.gui.ComponentPath; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.Optional; +import java.util.function.Consumer; +import java.util.HashSet; +import java.util.Set; + +public class BindConsumerScreen extends Screen { + private final ControllerImpl controller; + private EventSource.Subscription inputSubscription; + private LogicalInput capturedInput; + private final Set eligibleInputs = new HashSet<>(); + /// The binding currently in the slot being rebound. + private final Optional current; + private final Consumer> onResult; + private final Screen backgroundScreen; + /// Focus is lost while this screen covers the background, so it is put back on return. + private final @Nullable ComponentPath backgroundFocus; + private final BindController.BindControllerElement widgetToFocus; + + private int ticksTillClose; + private int ticksTillInput; + + public BindConsumerScreen( + ControllerImpl controller, + Optional current, + Consumer> onResult, + BindController.BindControllerElement widgetToFocus, + Screen backgroundScreen + ) { + super(Component.empty()); + this.controller = controller; + this.current = current; + this.onResult = onResult; + this.widgetToFocus = widgetToFocus; + this.backgroundScreen = backgroundScreen; + this.backgroundFocus = backgroundScreen.getCurrentFocusPath(); + this.ticksTillInput = 5; + } + + @Override + protected void init() { + if (inputSubscription != null) { + inputSubscription.close(); + } + if (controller == null) { + return; + } + inputSubscription = controller.input().inputPipeline().sourceLogicalSignals().subscribe(signal -> { + if (ticksTillInput != 0 || ticksTillClose != 0 || capturedInput != null) { + return; + } + if (signal instanceof LogicalSignal.ButtonPress press) { + eligibleInputs.add(press.input()); + } else if (signal instanceof LogicalSignal.ButtonRelease release && eligibleInputs.remove(release.input())) { + // Chords release before singles; releases also capture presses longer than a tap. + capturedInput = release.input(); + } + }); + } + + @Override + public void removed() { + if (inputSubscription != null) { + inputSubscription.close(); + } + } + + @Override + public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + Dimension dim = widgetToFocus.awaitingSlotDimension(); + + backgroundScreen.extractRenderStateWithTooltipAndSubtitles(graphics, dim.centerX(), dim.centerY(), a); + + graphics.nextStratum(); + + // darken everything except the widget + graphics.fill(0, 0, width, dim.y(), 0x80000000); + graphics.fill(0, dim.y(), dim.x(), height, 0x80000000); + graphics.fill(dim.xLimit(), dim.y(), width, height, 0x80000000); + graphics.fill(dim.x(), dim.yLimit(), dim.xLimit(), height, 0x80000000); + + super.extractRenderState(graphics, mouseX, mouseY, a); + } + + @Override + public void extractBackground(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) { + // do not render background + } + + @Override + public void tick() { + if (ticksTillClose > 0) { + ticksTillClose--; + if (ticksTillClose == 0) { + widgetToFocus.awaitingControllerInput = false; + // don't call setScreen because will cause background to re-init + MinecraftUtil.forceSetScreen(backgroundScreen); + // Restore before the screen context reactivates, which would otherwise focus the first element. + if (backgroundFocus != null && backgroundScreen.getFocused() == null) { + backgroundFocus.applyFocus(true); + } + ScreenRuntime.onScreenChanged(); + } + } + + if (ticksTillInput > 0) { + ticksTillInput--; + if (ticksTillInput > 0) { + return; + } + } + + // tick runs after all controller input ticks + + if (capturedInput != null) { + // Rebinding keeps the slot's gesture override. + LogicalInput input = capturedInput; + onResult.accept(Optional.of(current + .map(binding -> new ActionBinding(input, binding.gesture())) + .orElseGet(() -> ActionBinding.of(input)))); + capturedInput = null; + returnToBackground(); + } + } + + @Override + public boolean keyPressed(@NonNull KeyEvent keyEvent) { + if (ticksTillInput == 0 && ticksTillClose == 0 + && (keyEvent.key() == InputConstants.KEY_BACKSPACE || keyEvent.key() == InputConstants.KEY_DELETE)) { + onResult.accept(Optional.empty()); + returnToBackground(); + return true; + } + boolean consumed = super.keyPressed(keyEvent); + if (consumed) { + return true; + } + + if (ticksTillInput > 0) { + + return false; + + } + returnToBackground(); + return true; + } + + @Override + public boolean mouseClicked(@NonNull MouseButtonEvent mouseButtonEvent, boolean doubleClick) { + boolean consumed = super.mouseClicked(mouseButtonEvent, doubleClick); + if (consumed) { + return true; + } + + if (ticksTillInput > 0) { + + return false; + + } + returnToBackground(); + return true; + } + + @Override + public boolean mouseDragged(@NonNull MouseButtonEvent mouseButtonEvent, double dx, double dy) { + boolean consumed = super.mouseDragged(mouseButtonEvent, dx, dy); + if (consumed) { + return true; + } + + if (ticksTillInput > 0) { + + return false; + + } + returnToBackground(); + return true; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double amount, double d) { + boolean consumed = super.mouseScrolled(mouseX, mouseY, amount, d); + if (consumed) { + return true; + } + + if (ticksTillInput > 0) { + + return false; + + } + returnToBackground(); + return true; + } + + @Override + public void onClose() { + returnToBackground(); + } + + private void returnToBackground() { + if (inputSubscription != null) { + inputSubscription.close(); + } + ticksTillClose = 5; + } +} diff --git a/src/main/java/dev/isxander/controlify/gui/screen/BluetoothWarningScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/BluetoothWarningScreen.java similarity index 79% rename from src/main/java/dev/isxander/controlify/gui/screen/BluetoothWarningScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/BluetoothWarningScreen.java index 3ec03631c..12b792119 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/BluetoothWarningScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/BluetoothWarningScreen.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.misc.BluetoothDeviceComponent; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.misc.BluetoothDeviceComponent; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.gui.screens.ConfirmScreen; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.CommonComponents; diff --git a/src/main/java/dev/isxander/controlify/gui/screen/ControlifySettingsScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/ControlifySettingsScreen.java similarity index 85% rename from src/main/java/dev/isxander/controlify/gui/screen/ControlifySettingsScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/ControlifySettingsScreen.java index 54057a692..558168fd6 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/ControlifySettingsScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/ControlifySettingsScreen.java @@ -4,22 +4,23 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; import com.google.common.collect.ImmutableList; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.gui.components.PlainTextWidget; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.gui.components.PlainTextWidget; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import dev.isxander.controlify.mixins.feature.ui.AbstractSelectionListAccessor; -import dev.isxander.controlify.screenop.ScreenControllerEventListener; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.MinecraftUtil; import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.*; @@ -37,14 +38,13 @@ import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.util.Mth; -import net.minecraft.util.Util; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import java.util.List; import java.util.Optional; -public class ControlifySettingsScreen extends Screen implements ScreenControllerEventListener { +public class ControlifySettingsScreen extends Screen { private final @Nullable Screen parent; private Button globalSettingsButton, doneButton, controllerNotDetectedButton; @@ -62,7 +62,6 @@ protected void init() { this.footerHeight = this.height - this.footerY; this.mainPaneHeight = this.footerY; - // Top Links Component donateText = Component.translatable("controlify.gui.carousel.donate") .withStyle(ChatFormatting.GOLD, ChatFormatting.BOLD); @@ -79,13 +78,12 @@ protected void init() { }, font)); artCreditBtn.setTabOrderGroup(2); - // Controller Not Detected Button controllerNotDetectedButton = this.addRenderableWidget( Button.builder( Component.translatable("controlify.gui.carousel.controller_not_detected_btn"), btn -> CUtil.openUri("https://docs.isxander.dev/controlify/users/controller-issues#my-controller-is-not-detected") - ) + ) .pos(width / 2 - 75, (this.height - 36) / 2 + 10) .tooltip(Tooltip.create(Component.translatable("controlify.gui.carousel.controller_not_detected_btn.tooltip"))) .build() @@ -93,7 +91,6 @@ protected void init() { controllerNotDetectedButton.visible = !hasController(); FrameLayout.centerInRectangle(controllerNotDetectedButton, 0, 0, this.width, this.mainPaneHeight); - // Footer GridLayout grid = new GridLayout().columnSpacing(10); GridLayout.RowHelper rowHelper = grid.createRowHelper(2); @@ -116,7 +113,6 @@ protected void init() { grid.arrangeElements(); FrameLayout.centerInRectangle(grid, 0, this.footerY, this.width, this.footerHeight); - // Main Pane if (!this.controllerNotDetectedButton.visible) { int mainPaneX = 0; @@ -131,28 +127,28 @@ protected void init() { int activeProfileIndex = Controlify.instance().config().getActiveProfileIndex(); if (Controlify.instance().config().getSettings().globalSettings().showSplitscreenAd && false) { ProfileSlotEntry profileSlotEntry = new ProfileSlotEntry( - activeProfileIndex, - mainPaneX + slotPaddingX, mainPaneY + slotPaddingY, - profileSlotWidth / 2 - (slotPaddingX / 2), profileSlotHeight + activeProfileIndex, + mainPaneX + slotPaddingX, mainPaneY + slotPaddingY, + profileSlotWidth / 2 - (slotPaddingX / 2), profileSlotHeight ); this.addRenderableWidget(profileSlotEntry); SplitscreenAdvertisementSlotEntry splitscreenAdEntry = new SplitscreenAdvertisementSlotEntry( - mainPaneX + slotPaddingX + profileSlotWidth / 2 + (slotPaddingX / 2), mainPaneY + slotPaddingY, - profileSlotWidth / 2 - (slotPaddingX / 2), profileSlotHeight + mainPaneX + slotPaddingX + profileSlotWidth / 2 + (slotPaddingX / 2), mainPaneY + slotPaddingY, + profileSlotWidth / 2 - (slotPaddingX / 2), profileSlotHeight ); this.addRenderableWidget(splitscreenAdEntry); } else { ProfileSlotEntry profileSlotEntry = new ProfileSlotEntry( - activeProfileIndex, - mainPaneX + slotPaddingX, mainPaneY + slotPaddingY, - profileSlotWidth, profileSlotHeight + activeProfileIndex, + mainPaneX + slotPaddingX, mainPaneY + slotPaddingY, + profileSlotWidth, profileSlotHeight ); this.addRenderableWidget(profileSlotEntry); } } - ButtonGuideApi.addGuideToButton(globalSettingsButton, ControlifyBindings.GUI_ABSTRACT_ACTION_1, ButtonGuidePredicate.always()); - ButtonGuideApi.addGuideToButton(doneButton, ControlifyBindings.GUI_BACK, ButtonGuidePredicate.always()); + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get(this).buttonGuide().addGuideToButton(globalSettingsButton, ControlifyActions.GUI_ABSTRACT_ACTION_1, ButtonGuidePredicate.always()); + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get(this).buttonGuide().addGuideToButton(doneButton, ControlifyActions.GUI_BACK, ButtonGuidePredicate.always()); } @Override @@ -192,14 +188,6 @@ public void extractBackground(@NonNull GuiGraphicsExtractor graphics, int mouseX ); } - @Override - public void onControllerInput(ControllerEntity controller) { - if (ControlifyBindings.GUI_ABSTRACT_ACTION_1.on(controller).justPressed()) { - // Button impl does not use NonNull parameter - globalSettingsButton.onPress(null); - } - } - @Override public void onClose() { MinecraftUtil.setScreen(parent); @@ -207,7 +195,7 @@ public void onClose() { private boolean hasController() { return Controlify.instance().getControllerManager() - .map(c -> !c.getConnectedControllers().isEmpty()) + .map(c -> !c.getAllControllers().isEmpty()) .orElse(false); } @@ -333,12 +321,12 @@ private void updateProfileInfo(boolean force) { ? Component.translatable("controlify.gui.carousel.entry.profile", displayedProfileIndex) : Component.translatable("controlify.gui.carousel.entry.named_profile", displayedProfileIndex, profileSettings.name); - Optional controller = this.getController(profileSettings); + Optional controller = this.getController(profileSettings); Component controllerName; Identifier controllerIcon; boolean controllerConnected; if (controller.isPresent()) { - ControllerEntity entity = controller.get(); + ControllerImpl entity = controller.get(); controllerName = Component.literal(entity.name()); controllerIcon = entity.info().type().getIconSprite(); controllerConnected = true; @@ -359,10 +347,10 @@ private void updateProfileInfo(boolean force) { } if (!force - && profileName.equals(displayedProfileName) - && controllerName.equals(displayedControllerName) - && controllerIcon.equals(displayedControllerIcon) - && controllerConnected == displayedControllerConnected) { + && profileName.equals(displayedProfileName) + && controllerName.equals(displayedControllerName) + && controllerIcon.equals(displayedControllerIcon) + && controllerConnected == displayedControllerConnected) { return; } @@ -390,14 +378,12 @@ private ProfileSettings getProfileSettings() { return profile; } - private Optional getController(ProfileSettings profileSettings) { + private Optional getController(ProfileSettings profileSettings) { if (profileSettings.controllerUid == null) { return Controlify.instance().getCurrentController(); } return Controlify.instance().getControllerManager().flatMap(manager -> - manager.getConnectedControllers().stream() - .filter(controller -> profileSettings.controllerUid.equals(controller.uid())) - .findFirst() + manager.getControllerByUid(profileSettings.controllerUid) ); } @@ -411,7 +397,6 @@ private Optional getController(ProfileSettings profileSettings return NarrationPriority.NONE; } - @Override public void updateNarration(NarrationElementOutput narrationElementOutput) { narrationElementOutput.add(NarratedElementType.TITLE, this.profileNameText.getMessage()); @@ -477,7 +462,6 @@ public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouse return this.children; } - @Override public @NonNull NarrationPriority narrationPriority() { return NarrationPriority.NONE; @@ -485,7 +469,23 @@ public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouse @Override public void updateNarration(@NonNull NarrationElementOutput narrationElementOutput) { + } + } + + public static final class Behaviour implements ScreenBehaviour { + private final ControlifySettingsScreen screen; + + public Behaviour(ScreenContext context) { + this.screen = context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + this.screen.globalSettingsButton.onPress(new net.minecraft.client.input.KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN, 0, 0)); + } + next.run(); } } } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/ControllerConfigScreenFactory.java similarity index 69% rename from src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/ControllerConfigScreenFactory.java index 26a31aaff..3fcbe9abb 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/ControllerConfigScreenFactory.java @@ -4,46 +4,54 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; -import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.api.action.Action; import dev.isxander.controlify.api.contextual.GuideVerbosity; -import dev.isxander.controlify.bindings.BindContext; -import dev.isxander.controlify.bindings.ControlifyBindApiImpl; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.bindings.input.EmptyInput; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.config.settings.profile.GenericControllerSettings; -import dev.isxander.controlify.config.settings.profile.InputSettings; -import dev.isxander.controlify.controller.*; -import dev.isxander.controlify.controller.gyro.GyroButtonMode; -import dev.isxander.controlify.controller.gyro.GyroYawMode; -import dev.isxander.controlify.controller.input.DeadzoneGroup; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.controller.input.Inputs; -import dev.isxander.controlify.controller.rumble.RumbleComponent; -import dev.isxander.controlify.gui.controllers.BindController; -import dev.isxander.controlify.gui.controllers.Deadzone2DImageRenderer; -import dev.isxander.controlify.ingame.InputCurves; -import dev.isxander.controlify.rumble.BasicRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.server.ServerPolicies; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ActionDispatcher; +import dev.isxander.controlify.impl.action.activator.LeafConfig; +import dev.isxander.controlify.impl.action.ActionLayerRegistryImpl; +import dev.isxander.controlify.impl.action.BindingConflicts; +import dev.isxander.controlify.api.action.ActionBinding; +import dev.isxander.controlify.impl.action.ActionImpl; +import dev.isxander.controlify.impl.action.ActionSpecRegistryImpl; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.config.settings.profile.GenericControllerSettings; +import dev.isxander.controlify.impl.config.settings.profile.InputSettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.gyro.GyroButtonMode; +import dev.isxander.controlify.impl.controller.gyro.GyroYawMode; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.impl.controller.rumble.RumbleComponentImpl; +import dev.isxander.controlify.impl.controller.rumble.RumbleSource; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.gui.controllers.BindController; +import java.util.Locale; +import net.minecraft.network.chat.ComponentUtils; +import dev.isxander.controlify.impl.gui.controllers.BindingInfoImageRenderer; +import dev.isxander.controlify.impl.gui.controllers.Deadzone2DImageRenderer; +import dev.isxander.controlify.impl.ingame.InputCurves; +import dev.isxander.controlify.impl.input.mapping.DeadzoneGroup; +import dev.isxander.controlify.impl.server.ServerPolicies; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import dev.isxander.yacl3.api.*; import dev.isxander.yacl3.api.controller.*; import net.minecraft.ChatFormatting; -import net.minecraft.network.chat.MutableComponent; -import net.minecraft.util.Util; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; import net.minecraft.resources.Identifier; +import net.minecraft.util.Util; import org.jetbrains.annotations.Nullable; import java.util.*; @@ -56,13 +64,14 @@ public class ControllerConfigScreenFactory { private static final ValueFormatter percentOrOffFormatter = v -> v == 0 ? CommonComponents.OPTION_OFF : percentFormatter.format(v); private static final Component newOptionLabel = Component.translatable("controlify.gui.new_options.label").withStyle(ChatFormatting.GOLD); private static final ValueFormatter ticksToMillisFormatter = v -> Component.literal(String.format("%03dms", v * 50)); + private static final ValueFormatter millisFormatter = v -> Component.literal(v + "ms"); private final List> newOptions = new ArrayList<>(); public static Screen generateConfigScreen( Screen parent, ProfileSettings settings, ProfileSettings defaults, - @Nullable ControllerEntity controller + @Nullable ControllerImpl controller ) { return new ControllerConfigScreenFactory().generateConfigScreen0( parent, @@ -76,7 +85,7 @@ private Screen generateConfigScreen0( Screen parent, ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { String initialControllerUid = settings.controllerUid; var advancedCategory = createAdvancedCategory(settings, defaults, controller); @@ -102,7 +111,7 @@ private Screen generateConfigScreen0( private ConfigCategory createBasicCategory( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { var sensitivityGroup = makeSensitivityGroup(settings, defaults, controller); var controlsGroup = makeControlsGroup(settings, defaults, controller); @@ -174,15 +183,11 @@ private OptionGroup makeProfileGroup(ProfileSettings settings) { private Optional makeSensitivityGroup( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { var builder = OptionGroup.createBuilder() .name(Component.translatable("controlify.gui.config.group.sensitivity")); - if (controller.isPresent() && controller.get().input().isEmpty()) { - builder.option(LabelOption.create(Component.literal("TODO warning no input component"))); - } - InputSettings.SensitivitySettings sens = settings.input.sensitivity; InputSettings.SensitivitySettings def = defaults.input.sensitivity; @@ -268,10 +273,11 @@ private Optional makeSensitivityGroup( private Optional makeControlsGroup( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { - if (controller.isEmpty()) + if (controller.isEmpty()) { return Optional.empty(); + } ValueFormatter holdToggleFormatter = v -> Component.translatable("controlify.gui.format.hold_toggle." + (v ? "toggle" : "hold")); @@ -331,7 +337,7 @@ private Optional makeControlsGroup( private Optional makeAccessibilityGroup( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { GenericControllerSettings gSettings = settings.generic; GenericControllerSettings gDefaults = defaults.generic; @@ -415,13 +421,13 @@ private Optional makeAccessibilityGroup( private Optional makeDeadzoneGroup( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { - Optional inputOpt = controller.flatMap(ControllerEntity::input); - if (inputOpt.isEmpty()) + if (controller.isEmpty()) { return Optional.empty(); + } - InputComponent input = inputOpt.get(); + InputComponentImpl input = controller.get().input(); InputSettings config = settings.input; InputSettings def = defaults.input; @@ -432,7 +438,7 @@ private Optional makeDeadzoneGroup( group.option(LabelOption.create(Component.translatable("controlify.gui.stickdrift_warning").withStyle(ChatFormatting.RED))); - for (DeadzoneGroup deadzoneGroup : input.getDeadzoneGroups().values()) { + for (DeadzoneGroup deadzoneGroup : input.controllerMapping().deadzones().values()) { Identifier groupName = deadzoneGroup.name(); Component name = Component.translatable("controlify.deadzone_group." + groupName.getNamespace() + "." + groupName.getPath()); @@ -464,17 +470,50 @@ private Optional makeDeadzoneGroup( .build()) .binding(def.buttonActivationThreshold, () -> config.buttonActivationThreshold, v -> config.buttonActivationThreshold = v) .controller(opt -> FloatSliderControllerBuilder.create(opt) - .range(0f, 1f).step(0.01f) + .range(0.01f, 1f).step(0.01f) .formatValue(percentFormatter)) .build()); + InputSettings.GestureSettings gestures = config.gestures; + InputSettings.GestureSettings gestureDefaults = def.gestures; + group.option(Option.createBuilder() + .name(Component.translatable("controlify.gui.gesture_tap_max")) + .description(OptionDescription.createBuilder() + .text(Component.translatable("controlify.gui.gesture_tap_max.tooltip")) + .build()) + .binding(gestureDefaults.tapMaxMs, () -> gestures.tapMaxMs, v -> gestures.tapMaxMs = v) + .controller(opt -> IntegerSliderControllerBuilder.create(opt) + .range(50, 500).step(10) + .formatValue(millisFormatter)) + .build()); + group.option(Option.createBuilder() + .name(Component.translatable("controlify.gui.gesture_double_tap_gap")) + .description(OptionDescription.createBuilder() + .text(Component.translatable("controlify.gui.gesture_double_tap_gap.tooltip")) + .build()) + .binding(gestureDefaults.doubleTapGapMs, () -> gestures.doubleTapGapMs, v -> gestures.doubleTapGapMs = v) + .controller(opt -> IntegerSliderControllerBuilder.create(opt) + .range(50, 600).step(10) + .formatValue(millisFormatter)) + .build()); + group.option(Option.createBuilder() + .name(Component.translatable("controlify.gui.gesture_hold")) + .description(OptionDescription.createBuilder() + .text(Component.translatable("controlify.gui.gesture_hold.tooltip")) + .build()) + .binding(gestureDefaults.holdMs, () -> gestures.holdMs, v -> gestures.holdMs = v) + .controller(opt -> IntegerSliderControllerBuilder.create(opt) + .range(200, 1500).step(50) + .formatValue(millisFormatter)) + .build()); + return Optional.of(group.build()); } private ConfigCategory createAdvancedCategory( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { var builder = ConfigCategory.createBuilder() .name(Component.translatable("controlify.config.category.advanced")); @@ -490,16 +529,16 @@ private ConfigCategory createAdvancedCategory( private Optional makeControllerMappingGroup( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controllerOpt ) { - Optional inputOpt = controller.flatMap(ControllerEntity::input); - if (inputOpt.isEmpty()) + if (controllerOpt.isEmpty()) { return Optional.empty(); - InputComponent input = inputOpt.get(); + } + ControllerImpl controller = controllerOpt.get(); DeviceSettings config = Controlify.instance().config().getSettings() - .getOrCreateDeviceSettings(controller.get().uid()); - DeviceSettings def = DeviceSettings.defaults(controller.map(ControllerEntity::uid).orElse("")); + .getOrCreateDeviceSettings(controller.uid()); + DeviceSettings def = DeviceSettings.defaults(controller.uid()); return Optional.of(OptionGroup.createBuilder() .name(Component.translatable("controlify.gui.group.controller_mapping")) @@ -507,12 +546,15 @@ private Optional makeControllerMappingGroup( .option(ButtonOption.createBuilder() .name(Component.translatable("controlify.gui.create_gamepad_mapping")) .description(OptionDescription.of(Component.translatable("controlify.gui.create_gamepad_mapping.tooltip"))) - .action((screen, button) -> MinecraftUtil.setScreen(ControllerMappingMakerScreen.createGamepadMapping(input, screen))) + .action((screen, button) -> MinecraftUtil.setScreen(ControllerMappingMakerScreen.createGamepadMapping(controller, screen))) .build()) .option(ButtonOption.createBuilder() .name(Component.translatable("controlify.gui.clear_mapping")) .description(OptionDescription.of(Component.translatable("controlify.gui.clear_mapping.tooltip"))) - .action((screen, button) -> config.mapping = def.mapping) + .action((screen, button) -> { + config.mapping = def.mapping; + controller.input().reloadControllerMapping(); + }) .build()) .collapsed(true) .build()); @@ -521,7 +563,7 @@ private Optional makeControllerMappingGroup( private Optional makeVibrationGroup( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { var vibrationGroup = OptionGroup.createBuilder() .name(Component.translatable("controlify.gui.group.vibration")) @@ -556,7 +598,7 @@ private Optional makeVibrationGroup( .controller(TickBoxControllerBuilder::create) .build()); - for (RumbleSource source : RumbleSource.values()) { + for (RumbleSource source : Controlify.instance().rumbleSourceManager().sources()) { var option = Option.createBuilder() .name(Component.translatable("controlify.vibration_strength." + source.id().getNamespace() + "." + source.id().getPath())) .description(OptionDescription.createBuilder() @@ -577,21 +619,21 @@ private Optional makeVibrationGroup( vibrationGroup.option(option); } if (controller.isPresent() && controller.get().rumble().isPresent()) { - RumbleComponent rumble = controller.get().rumble().get(); + RumbleComponentImpl rumble = controller.get().rumble().get(); vibrationGroup.option(ButtonOption.createBuilder() .name(Component.translatable("controlify.gui.test_vibration")) .description(OptionDescription.of(Component.translatable("controlify.gui.test_vibration.tooltip"))) .action((screen, btn) -> { - rumble.rumbleManager().play( - RumbleSource.MASTER, - BasicRumbleEffect.byTime(t -> new RumbleState(0f, t), 20) - .seq(BasicRumbleEffect.byTime(t -> new RumbleState(0f, 1 - t), 20)) + rumble.playEffect( + CUtil.rl("test_vibration"), + RumbleEffect.finite().byTime(t -> new RumbleState(0f, t), 20) + .then(RumbleEffect.finite().byTime(t -> new RumbleState(0f, 1 - t), 20)) .repeat(3) - .seq(BasicRumbleEffect.constant(1f, 0f, 5) - .seq(BasicRumbleEffect.constant(0f, 1f, 5)) + .then(RumbleEffect.finite().constant(1f, 0f, 5) + .then(RumbleEffect.finite().constant(0f, 1f, 5)) .repeat(10) ) - .earlyFinish(BasicRumbleEffect.finishOnScreenChange()) + .stopCondition(() -> MinecraftUtil.getScreen() != screen) ); }) .build()); @@ -603,7 +645,7 @@ private Optional makeVibrationGroup( private Optional makeGyroGroup( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { var gyroGroup = OptionGroup.createBuilder() .name(Component.translatable("controlify.gui.group.gyro")) @@ -633,17 +675,7 @@ private Optional makeGyroGroup( o.requestSetDefault(); })) .build()); - var relativeModeOpt = Option.createBuilder() - .name(Component.translatable("controlify.gui.gyro_behaviour")) - .description(val -> OptionDescription.createBuilder() - .text(Component.translatable("controlify.gui.gyro_behaviour.tooltip")) - .text(val ? Component.translatable("controlify.gui.gyro_behaviour.relative.tooltip") : Component.translatable("controlify.gui.gyro_behaviour.absolute.tooltip")) - .build()) - .binding(defaults.gyro.relativeMode, () -> settings.gyro.relativeMode, v -> settings.gyro.relativeMode = v) - .controller(opt -> BooleanControllerBuilder.create(opt) - .formatValue(v -> v ? Component.translatable("controlify.gui.gyro_behaviour.relative") : Component.translatable("controlify.gui.gyro_behaviour.absolute"))) - .build(); - gyroGroup.option(relativeModeOpt); + gyroGroup.option(Util.make(() -> { var option = Option.createBuilder() .name(Component.translatable("controlify.gui.gyro_yaw_mode")) @@ -696,6 +728,21 @@ private Optional makeGyroGroup( gyroOptions.add(opt); return opt; })); + gyroGroup.option(Util.make(() -> { + var option = Option.createBuilder() + .name(Component.translatable("controlify.gui.gyro_low_pass_cutoff_hz")) + .description(OptionDescription.of(Component.translatable("controlify.gui.gyro_low_pass_cutoff_hz.tooltip"))) + .binding(defaults.gyro.lowPassCutoffHz, () -> settings.gyro.lowPassCutoffHz, v -> settings.gyro.lowPassCutoffHz = v) + .controller(opt -> FloatSliderControllerBuilder.create(opt) + .range(0f, 100f) + .step(1f) + .formatValue(v -> v == 0 ? CommonComponents.OPTION_OFF + : Component.translatable("controlify.gui.gyro_low_pass_cutoff_hz.value", v.intValue()))) + .available(gyroSensitivity.pendingValue() > 0) + .build(); + gyroOptions.add(option); + return option; + })); gyroGroup.option(Util.make(() -> { var opt = Option.createBuilder() .name(Component.translatable("controlify.gui.flick_stick")) @@ -716,7 +763,7 @@ private Optional makeGyroGroup( private Optional makeTriggerEffectsGroup( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { var group = OptionGroup.createBuilder() .name(Component.translatable("controlify.gui.group.trigger_effects")) @@ -724,7 +771,7 @@ private Optional makeTriggerEffectsGroup( .text(Component.translatable("controlify.gui.trigger_effects.tooltip")) .build()); - if (controller.isPresent() && controller.get().dualSense().isEmpty()) { + if (controller.isPresent() && controller.get().dualsense().isEmpty()) { group.collapsed(true); group.option(LabelOption.create(Component.translatable("controlify.gui.group.trigger_effects.no_dualsense.tooltip").withStyle(ChatFormatting.RED))); } @@ -742,7 +789,7 @@ private Optional makeTriggerEffectsGroup( private Optional makeBindsCategory( ProfileSettings settings, ProfileSettings defaults, - Optional controller + Optional controller ) { var category = ConfigCategory.createBuilder() @@ -751,7 +798,7 @@ private Optional makeBindsCategory( InputSettings.RadialMenuSettings radialConfig = settings.input.radialMenu; InputSettings.RadialMenuSettings radialDef = defaults.input.radialMenu; - List optionBinds = new ArrayList<>(); + List optionActions = new ArrayList<>(); category .optionIf(controller.isPresent(), ButtonOption.createBuilder() @@ -776,26 +823,37 @@ private Optional makeBindsCategory( .range(2, 40).step(1).formatValue(ticksToMillisFormatter)) .build()); - Collection allBindings = controller.isPresent() - ? controller.get().input().map(InputComponent::getAllBindings).orElse(List.of()) - : ControlifyBindApiImpl.INSTANCE.provideBindsForController(null); // when giving null as controller, we get all binds - - groupBindings(allBindings).forEach((categoryName, bindGroup) -> { + Collection allActions = controller + .>map(c -> c.input().getAllActions().stream() + .filter(action -> action.spec().shouldExpose().test(c)) + .map(action -> (Action) action).toList()) + .orElseGet(() -> ActionSpecRegistryImpl.INSTANCE.getActionSpecs().stream() + .map(spec -> { + List defaultBindings = Controlify.instance().defaultBindManager() + .getDefaultBindProvider(ControllerType.DEFAULT.id()).getDefaultBindings(spec.id()); + var action = new ActionImpl(spec, + settings.input.bindings.resolve(spec.id(), defaultBindings), defaultBindings, + Controlify.instance().activatorConfigManager().getConfig(spec.id()), + new ActionDispatcher()); + action.reset(); + return (Action) action; + }).toList()); + + groupActions(allActions).forEach((categoryName, bindGroup) -> { var controlsGroup = OptionGroup.createBuilder() .name(categoryName); - controlsGroup.options(bindGroup.stream().map(binding -> { - Option.Builder option = createBindingOpt(binding, controller.orElse(null)) - .addListener((opt, val) -> updateConflictingBinds(optionBinds)); - - Option built = option.build(); - optionBinds.add(new OptionBindPair(built, binding)); - return built; - }).toList()); + for (Action action : bindGroup) { + Option> built = createActionOpt(action, settings, controller.orElse(null)) + .addListener((opt, val) -> updateConflictingActions(optionActions)) + .build(); + optionActions.add(new OptionActionPair(built, (ActionImpl) action)); + controlsGroup.option(built); + } category.group(controlsGroup.build()); }); - updateConflictingBinds(optionBinds); + updateConflictingActions(optionActions); category.option(ButtonOption.createBuilder() .name(Component.translatable("controlify.gui.reset_all_binds")) @@ -803,10 +861,8 @@ private Optional makeBindsCategory( .text(Component.translatable("controlify.gui.reset_all_binds.tooltip")) .build()) .action((screen, opt) -> { - for (OptionBindPair pair : optionBinds) { - // who needs a type system? - // noinspection unchecked - ((Option) pair.option()).requestSet(pair.binding.defaultInput()); + for (OptionActionPair pair : optionActions) { + pair.option().requestSet(pair.action().defaultBindings()); } }) .build()); @@ -814,64 +870,136 @@ private Optional makeBindsCategory( return Optional.of(category.build()); } - private void updateConflictingBinds(List all) { - all.forEach(pair -> ((BindController) pair.option().controller()).setConflicting(false)); + private void updateConflictingActions(List all) { + Map byAction = new HashMap<>(); + List entries = new ArrayList<>(); + for (OptionActionPair pair : all) { + byAction.put(pair.action().spec().id(), pair); + entries.add(new BindingConflicts.Entry(pair.action().spec().id(), pair.action().spec().contexts(), + pair.option().pendingValue(), pair.action().activatorConfig())); + } - for (OptionBindPair opt : all) { - Set ctxs = opt.binding().contexts(); + Map worst = new HashMap<>(); + Map> byPair = new HashMap<>(); + for (BindingConflicts.Conflict conflict : BindingConflicts.find(entries, ActionLayerRegistryImpl.INSTANCE.getOrderedContexts())) { + OptionActionPair pair = byAction.get(conflict.action()); + byPair.computeIfAbsent(pair, _ -> new ArrayList<>()).add(conflict); + if (conflict.bindingIndex() < BindController.SLOTS) { + var kinds = worst.computeIfAbsent(pair, _ -> new BindingConflicts.Kind[BindController.SLOTS]); + BindingConflicts.Kind current = kinds[conflict.bindingIndex()]; + // Kinds are declared most severe first. + if (current == null || conflict.kind().ordinal() < current.ordinal()) { + kinds[conflict.bindingIndex()] = conflict.kind(); + } + } + } + for (OptionActionPair pair : all) { + ((BindController) pair.option().controller()).setConflicts( + worst.getOrDefault(pair, new BindingConflicts.Kind[BindController.SLOTS]), + describeConflicts(pair, byPair.getOrDefault(pair, List.of()), byAction)); + } + } - List conflicting = all.stream() - .filter(pair -> pair.binding() != opt.binding()) - .filter(pair -> { - boolean contextsMatch = pair.binding().contexts() - .stream() - .anyMatch(ctxs::contains); - boolean bindMatches = pair.option().pendingValue().equals(opt.option().pendingValue()); - boolean bindIsNotEmpty = !(pair.option().pendingValue() instanceof EmptyInput); - return contextsMatch && bindMatches && bindIsNotEmpty; - }).toList(); + /// One heading per binding with conflicts, followed by each of its conflicts, most severe first. + private List describeConflicts( + OptionActionPair pair, List conflicts, Map byAction + ) { + List lines = new ArrayList<>(); + List bindings = pair.option().pendingValue(); + for (int index = 0; index < bindings.size(); index++) { + int bindingIndex = index; + List forBinding = conflicts.stream() + .filter(conflict -> conflict.bindingIndex() == bindingIndex) + .sorted(Comparator.comparing(BindingConflicts.Conflict::kind)) + .toList(); + if (forBinding.isEmpty()) { + continue; + } + lines.add(Component.translatable("controlify.gui.bind.conflicts_for", index + 1, + bindingGlyph(pair, bindings.get(index)))); + for (BindingConflicts.Conflict conflict : forBinding) { + lines.add(Component.literal(" • ").append(describeConflict(conflict, byAction.get(conflict.other())))); + } + } + return lines; + } - conflicting.forEach(conflict -> ((BindController) conflict.option().controller()).setConflicting(true)); + private Component bindingGlyph(OptionActionPair pair, ActionBinding binding) { + ControllerImpl controller = ((BindController) pair.option().controller()).controller; + Identifier fontNamespace = controller == null ? ControllerType.DEFAULT.id() : controller.info().type().id(); + return Controlify.instance().inputFontMapper() + .getComponentFromBinding(fontNamespace, binding, pair.action().activatorConfig().editableLeaf()); + } + + private Component describeConflict(BindingConflicts.Conflict conflict, OptionActionPair other) { + String key = "controlify.gui.bind.conflict." + conflict.kind().name().toLowerCase(Locale.ROOT); + ChatFormatting colour = switch (conflict.kind()) { + case CONFLICT -> ChatFormatting.RED; + case MODIFIER_LEAK -> ChatFormatting.GOLD; + case REDUNDANT, SHADOWED -> ChatFormatting.GRAY; + }; + if (conflict.kind() == BindingConflicts.Kind.REDUNDANT) { + return Component.translatable(key, conflict.otherBindingIndex() + 1).withStyle(colour); } + Component otherBinding = bindingGlyph(other, other.option().pendingValue().get(conflict.otherBindingIndex())); + return Component.translatable(key, other.action().spec().name(), otherBinding).withStyle(colour); } - private static Map> groupBindings(Collection bindings) { + private static Map> groupActions(Collection bindings) { return bindings.stream() - .collect(Collectors.groupingBy(InputBinding::category, LinkedHashMap::new, Collectors.toList())); + .collect(Collectors.groupingBy(a -> a.spec().category(), LinkedHashMap::new, Collectors.toList())); } - private static Option.Builder createBindingOpt(InputBindingSupplier bindingSupplier, ControllerEntity controller) { - return createBindingOpt(bindingSupplier.on(controller), controller); - } + private Option.Builder> createActionOpt(Action action, ProfileSettings settings, @Nullable ControllerImpl controller) { + Identifier fontNamespace = controller == null + ? ControllerType.DEFAULT.id() + : controller.info().type().id(); + Optional editableLeaf = ((ActionImpl) action).activatorConfig().editableLeaf(); + var fontMapper = Controlify.instance().inputFontMapper(); + // The description is built before the controller exists, but renders after it. + AtomicReference bindController = new AtomicReference<>(); + AtomicReference>> optionRef = new AtomicReference<>(); + + var description = OptionDescription.createBuilder() + .text(action.spec().description()); + if (editableLeaf.isPresent()) { + description.text(Component.translatable("controlify.gui.bind.gesture_hint", + ActionGlyphs.action(ControlifyActions.GUI_ABSTRACT_ACTION_1)).withStyle(ChatFormatting.GRAY)); + } + description.customImage(new BindingInfoImageRenderer(() -> { + List lines = new ArrayList<>(); + Option> option = optionRef.get(); + if (option != null && !option.pendingValue().equals(action.defaultBindings())) { + Component defaults = action.defaultBindings().isEmpty() + ? fontMapper.getComponentFromInput(fontNamespace, null) + : ComponentUtils.formatList(action.defaultBindings(), Component.literal(", "), + binding -> fontMapper.getComponentFromBinding(fontNamespace, binding, editableLeaf)); + lines.add(Component.translatable("controlify.gui.bind.default_bound_to", defaults)); + } + BindController controllerInstance = bindController.get(); + if (controllerInstance != null) { + lines.addAll(controllerInstance.conflictLines()); + } + return lines; + })); - private static Option.Builder createBindingOpt(InputBinding binding, @Nullable ControllerEntity controller) { - return Option.createBuilder() - .name(binding.name()) - .description(v -> OptionDescription.createBuilder() - .text(binding.description()) - .text(Component.translatable("controlify.gui.bind.currently_bound_to", - Component.empty() - .append(Controlify.instance().inputFontMapper().getComponentFromInputs( - controller.info().type().namespace(), - v.getRelevantInputs() - )) - .append(CommonComponents.SPACE) - .append(Inputs.getInputComponentAnd(v.getRelevantInputs())) - )) - .text(v.equals(binding.defaultInput()) ? Component.empty() : Component.translatable("controlify.gui.bind.default_bound_to", - Component.empty() - .append(Controlify.instance().inputFontMapper().getComponentFromInputs( - controller.info().type().namespace(), - binding.defaultInput().getRelevantInputs() - )) - .append(CommonComponents.SPACE) - .append(Inputs.getInputComponentAnd( - binding.defaultInput().getRelevantInputs() - )) - )) - .build()) - .binding(EmptyInput.INSTANCE, binding::boundInput, binding::setBoundInput) - .customController(opt -> new BindController(opt, controller)); + return Option.>createBuilder() + .name(action.spec().name()) + .description(description.build()) + .binding(action.defaultBindings(), action::bindings, bindings -> { + action.setBindings(bindings); + if (bindings.equals(action.defaultBindings())) { + settings.input.bindings.bindings.remove(action.spec().id()); + } else { + settings.input.bindings.bindings.put(action.spec().id(), bindings); + } + }) + .customController(opt -> { + optionRef.set(opt); + var created = new BindController(opt, controller, editableLeaf); + bindController.set(created); + return created; + }); } private static Identifier screenshot(String filename) { @@ -882,6 +1010,6 @@ private static MutableComponent notSupportedText(Component featureName) { return Component.translatable("controlify.gui.not_supported", featureName).withStyle(ChatFormatting.RED); } - private record OptionBindPair(Option option, InputBinding binding) { + private record OptionActionPair(Option> option, ActionImpl action) { } } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/ControllerMappingMakerScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/ControllerMappingMakerScreen.java similarity index 60% rename from src/main/java/dev/isxander/controlify/gui/screen/ControllerMappingMakerScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/ControllerMappingMakerScreen.java index 42c5c4b7d..6e3e69380 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/ControllerMappingMakerScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/ControllerMappingMakerScreen.java @@ -4,22 +4,24 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.input.*; -import dev.isxander.controlify.controller.input.mapping.MapType; -import dev.isxander.controlify.controller.input.mapping.MappingEntry; -import dev.isxander.controlify.controller.input.mapping.ControllerMapping; -import dev.isxander.controlify.screenop.ScreenControllerEventListener; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.controlify.utils.render.RenderUtils; -import dev.isxander.controlify.utils.ColorUtils; +package dev.isxander.controlify.impl.gui.screen; + +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.input.mapping.ControllerMapping; +import dev.isxander.controlify.impl.input.mapping.DeadzoneGroup; +import dev.isxander.controlify.impl.input.mapping.GamepadInputs; +import dev.isxander.controlify.impl.input.mapping.MapType; +import dev.isxander.controlify.impl.input.mapping.MappingEntry; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.ColorUtils; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.impl.utils.render.RenderUtils; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Tooltip; @@ -29,12 +31,21 @@ import net.minecraft.resources.Identifier; import org.jspecify.annotations.NonNull; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Set; -public class ControllerMappingMakerScreen extends Screen implements ScreenControllerEventListener, ScreenProcessorProvider, DontInterruptScreen { - private final InputComponent inputComponent; +public class ControllerMappingMakerScreen extends Screen implements DontInterruptScreen { + private static final float AXIS_PRESS_THRESHOLD = 0.5f; + private static final float AXIS_RELEASE_THRESHOLD = 0.25f; + + private final ControllerImpl controller; + private final Map capturedMappings = new LinkedHashMap<>(); + private final Set activeInputs = new HashSet<>(); + private EventSource.Subscription inputSubscription; private final ControllerMapping.Builder mappingBuilder = new ControllerMapping.Builder(); - private final ScreenProcessor screenProcessor = new ScreenProcessorImpl(this); private int delayTillNextStage = 20; @@ -71,148 +82,140 @@ public class ControllerMappingMakerScreen extends Screen implements ScreenContro private Button goBackButton; private final Screen lastScreen; - public ControllerMappingMakerScreen(InputComponent inputComponent, Screen lastScreen, List stages, Iterable deadzoneGroups) { + public ControllerMappingMakerScreen(ControllerImpl controller, Screen lastScreen, List stages, Iterable deadzoneGroups) { super(Component.literal("Gamepad Emulation Mapping Creator")); - this.inputComponent = inputComponent; + this.controller = controller; this.lastScreen = lastScreen; - this.stages = stages; - mappingBuilder.putDeadzoneGroups(deadzoneGroups); - - // otherwise we will be mapping something that is already mapped - DeviceSettings deviceSettings = Controlify.instance().config().getSettings() - .getOrCreateDeviceSettings(inputComponent.getController().uid()); - deviceSettings.mapping = null; + this.stages = stages.stream().map(MappingStage::new).toList(); + this.mappingBuilder.putDeadzoneGroups(deadzoneGroups); } - public static ControllerMappingMakerScreen createGamepadMapping(InputComponent inputComponent, Screen lastScreen) { - return new ControllerMappingMakerScreen(inputComponent, lastScreen, GAMEPAD_STAGES, GamepadInputs.DEADZONE_GROUPS); + public static ControllerMappingMakerScreen createGamepadMapping(ControllerImpl controller, Screen lastScreen) { + return new ControllerMappingMakerScreen(controller, lastScreen, GAMEPAD_STAGES, GamepadInputs.DEADZONE_GROUPS); } @Override protected void init() { addRenderableWidget( - goBackButton = Button.builder( - Component.translatable("controlify.gui.mapping_maker.go_back"), - button -> goBackStage() - ) + this.goBackButton = Button.builder( + Component.translatable("controlify.gui.mapping_maker.go_back"), + button -> goBackStage() + ) .bounds(width / 2 - 152, height - 60, 150, 20) .tooltip(Tooltip.create(Component.translatable("controlify.gui.mapping_maker.go_back.tooltip"))) .build() ); addRenderableWidget( Button.builder( - Component.translatable("controlify.gui.mapping_maker.no_map"), - button -> mapAsNone() - ) + Component.translatable("controlify.gui.mapping_maker.no_map"), + button -> mapAsNone() + ) .bounds(width / 2 + 2, height - 60, 150, 20) .tooltip(Tooltip.create(Component.translatable("controlify.gui.mapping_maker.no_map.tooltip"))) .build() ); - goBackButton.active = false; + goBackButton.active = currentStage != null && stages.indexOf(currentStage) > 0; } @Override public void tick() { - if (delayTillNextStage >= 0) { - delayTillNextStage--; - if (delayTillNextStage == -1) { - if (currentStage == null) { - this.setStage(stages.get(0)); - } else { - int index = stages.indexOf(currentStage); - int nextIndex = index + 1; - if (nextIndex >= stages.size()) { - onClose(); - } else { - this.setStage(stages.get(index + 1)); - goBackButton.active = true; - } - } - } - } else { - if (currentStage == null) { - this.setStage(stages.get(0)); - } else if (currentStage.isSatisfied() && delayTillNextStage == -1) { - delayTillNextStage = 20; - } + if (delayTillNextStage < 0 || delayTillNextStage-- > 0) { + return; + } - if (currentStage != null && !currentStage.isSatisfied()) { - ControllerStateView stateNow = this.inputComponent.stateNow(); - ControllerStateView stateThen = this.inputComponent.stateThen(); + int nextIndex = currentStage == null ? 0 : stages.indexOf(currentStage) + 1; + if (nextIndex >= stages.size()) { + onClose(); + return; + } + this.setStage(stages.get(nextIndex)); + if (currentStage.isSatisfied()) { + // A later step may already be mapped when revisiting an earlier one. + delayTillNextStage = 20; + } + } - processStage(currentStage, stateNow, stateThen); - } + @Override + public void added() { + if (this.inputSubscription != null) { + this.inputSubscription.close(); } + // Capture original source IDs before mappings, deadzones, gestures, and domain expansion. + this.inputSubscription = this.controller.input().inputPipeline() + .sourceRawPhysicalSignals().subscribe(this::onPhysicalSignal); } - private void processStage(MappingStage stage, ControllerStateView stateNow, ControllerStateView stateThen) { - for (Identifier button : stateNow.getButtons()) { - boolean now = stateNow.isButtonDown(button); - boolean prev = stateThen.isButtonDown(button); - - if (now != prev) { - MappingEntry mapping = switch (stage.outputType()) { - case BUTTON -> new MappingEntry.FromButton.ToButton(button, stage.originInput(), !now); - case AXIS -> new MappingEntry.FromButton.ToAxis(button, stage.originInput(), prev ? 1 : 0, now ? 1 : 0); - case HAT -> { - HatState state = now ? HatState.DOWN : HatState.UP; - yield new MappingEntry.FromButton.ToHat(button, stage.originInput(), HatState.CENTERED, state); - } - case NOTHING -> null; - }; - mappingBuilder.putMapping(mapping); - stage.setSatisfied(true); - return; - } + @Override + public void removed() { + if (this.inputSubscription != null) { + this.inputSubscription.close(); + this.inputSubscription = null; } + } - for (Identifier axis : stateNow.getAxes()) { - float now = stateNow.getAxisState(axis); - float prev = stateThen.getAxisState(axis); - float diff = prev - now; - if (Math.abs(diff) > 0.3f) { - MappingEntry mapping = switch (stage.outputType()) { - case BUTTON -> new MappingEntry.FromAxis.ToButton(axis, stage.originInput(), 0.5f); - case AXIS -> new MappingEntry.FromAxis.ToAxis(axis, stage.originInput(), 0, 0, 1, 1); - case HAT -> new MappingEntry.FromAxis.ToHat(axis, stage.originInput(), 0.5f, diff > 0 ? HatState.UP : HatState.DOWN); - case NOTHING -> null; - }; - mappingBuilder.putMapping(mapping); - stage.setSatisfied(true); + private void onPhysicalSignal(PhysicalSignal signal) { + Identifier input; + MapType inputType; + boolean pressed; + switch (signal) { + case PhysicalInputSignal.ButtonChanged button -> { + input = button.input(); + inputType = MapType.BUTTON; + pressed = button.state(); + } + case PhysicalInputSignal.AxisMoved axis -> { + input = axis.input(); + inputType = MapType.AXIS; + // Hysteresis prevents stick noise around the capture threshold from retriggering. + pressed = axis.value() >= (this.activeInputs.contains(input) + ? AXIS_RELEASE_THRESHOLD : AXIS_PRESS_THRESHOLD); + } + default -> { return; } } - for (Identifier hat : stateNow.getHats()) { - HatState now = stateNow.getHatState(hat); - HatState prev = stateThen.getHatState(hat); - if (now != prev) { - MappingEntry mapping = switch (stage.outputType()) { - case BUTTON -> new MappingEntry.FromHat.ToButton(hat, stage.originInput(), now); - case AXIS -> new MappingEntry.FromHat.ToAxis(hat, stage.originInput(), now, 0, 1); - case HAT -> new MappingEntry.FromHat.ToHat(hat, stage.originInput()); - case NOTHING -> null; - }; - mappingBuilder.putMapping(mapping); - stage.setSatisfied(true); - return; - } + // Track releases during the cooldown too, so held inputs cannot satisfy the next step. + if (!pressed) { + this.activeInputs.remove(input); + return; } + if (!this.activeInputs.add(input) || this.delayTillNextStage >= 0 + || this.currentStage == null || this.currentStage.isSatisfied()) { + return; + } + // MappingStage requires each physical source to have a single destination. + if (this.capturedMappings.values().stream().anyMatch(mapping -> mapping.sourceInput().equals(input))) { + return; + } + + Identifier output = this.currentStage.originInput(); + MappingEntry mapping = switch (inputType) { + case BUTTON -> switch (this.currentStage.outputType()) { + case BUTTON -> new MappingEntry.FromButton.ToButton(input, output, false); + case AXIS -> new MappingEntry.FromButton.ToAxis(input, output, 0, 1); + }; + case AXIS -> switch (this.currentStage.outputType()) { + case BUTTON -> new MappingEntry.FromAxis.ToButton(input, output, AXIS_PRESS_THRESHOLD); + case AXIS -> new MappingEntry.FromAxis.ToAxis(input, output, 0, 0, 1, 1); + }; + }; + this.capturedMappings.put(output, mapping); + this.currentStage.setSatisfied(true); + this.delayTillNextStage = 20; } private void setStage(MappingStage stage) { currentStage = stage; + goBackButton.active = stages.indexOf(stage) > 0; } private void mapAsNone() { - MappingEntry mapping = switch (currentStage.outputType()) { - case BUTTON -> new MappingEntry.FromNothing.ToButton(currentStage.originInput(), false); - case AXIS -> new MappingEntry.FromNothing.ToAxis(currentStage.originInput(), 0f); - case HAT -> new MappingEntry.FromNothing.ToHat(currentStage.originInput()); - case NOTHING -> null; - }; - mappingBuilder.putMapping(mapping); - + if (currentStage == null) { + return; + } + capturedMappings.remove(currentStage.originInput()); + currentStage.setSatisfied(false); delayTillNextStage = 0; } @@ -220,17 +223,24 @@ private void mapAsNone() { public void onClose() { MinecraftUtil.setScreen(lastScreen); DeviceSettings deviceSettings = Controlify.instance().config().getSettings() - .getOrCreateDeviceSettings(inputComponent.getController().uid()); + .getOrCreateDeviceSettings(controller.uid()); + capturedMappings.values().forEach(mappingBuilder::putMapping); deviceSettings.mapping = mappingBuilder.build(); + controller.input().reloadControllerMapping(); Controlify.instance().config().saveSafely(); } private void goBackStage() { + if (currentStage == null) { + return; + } int index = stages.indexOf(currentStage); int nextIndex = index - 1; if (nextIndex >= 0) { this.setStage(stages.get(index - 1)); currentStage.setSatisfied(false); + capturedMappings.remove(currentStage.originInput()); + delayTillNextStage = -1; } goBackButton.active = nextIndex > 0; } @@ -303,11 +313,6 @@ private static Component axis(String axisName, String direction) { return Component.translatable("controlify.gui.mapping_maker.instruction.axis", axisText, directionText); } - @Override - public ScreenProcessor screenProcessor() { - return screenProcessor; - } - public static class MappingStage { private final Identifier originInput; private final MapType outputType; @@ -320,8 +325,17 @@ public MappingStage(Identifier originInput, MapType outputType, Component name, this.originInput = originInput; this.outputType = outputType; this.name = name; - this.foreground = CUtil.rl("textures/gui/controllerdiagram/" + foreground + ".png");; - this.background = CUtil.rl("textures/gui/controllerdiagram/" + foreground + ".png");; + this.foreground = CUtil.rl("textures/gui/controllerdiagram/" + foreground + ".png"); + this.background = CUtil.rl("textures/gui/controllerdiagram/" + background + ".png"); + } + + private MappingStage(MappingStage template) { + this.originInput = template.originInput; + this.outputType = template.outputType; + this.name = template.name; + this.foreground = template.foreground; + this.background = template.background; + this.satisfied = false; } public Identifier originInput() { @@ -352,15 +366,4 @@ public void setSatisfied(boolean satisfied) { this.satisfied = satisfied; } } - - private static class ScreenProcessorImpl extends ScreenProcessor { - public ScreenProcessorImpl(ControllerMappingMakerScreen screen) { - super(screen); - } - - @Override - public void onControllerUpdate(ControllerEntity controller) { - - } - } } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/DontInterruptScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/DontInterruptScreen.java similarity index 77% rename from src/main/java/dev/isxander/controlify/gui/screen/DontInterruptScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/DontInterruptScreen.java index dcc9cd965..54bbabf7c 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/DontInterruptScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/DontInterruptScreen.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; public interface DontInterruptScreen { } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/GlobalSettingsScreenFactory.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/GlobalSettingsScreenFactory.java similarity index 93% rename from src/main/java/dev/isxander/controlify/gui/screen/GlobalSettingsScreenFactory.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/GlobalSettingsScreenFactory.java index 1e885fe5a..963dcf749 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/GlobalSettingsScreenFactory.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/GlobalSettingsScreenFactory.java @@ -4,18 +4,17 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.config.settings.GlobalSettings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.driver.steamdeck.SteamDeckUtil; -import dev.isxander.controlify.reacharound.ReachAroundMode; -import dev.isxander.controlify.server.ServerPolicies; -import dev.isxander.controlify.server.ServerPolicy; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.DebugDump; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.config.settings.GlobalSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.driver.steamdeck.SteamDeckUtil; +import dev.isxander.controlify.impl.reacharound.ReachAroundMode; +import dev.isxander.controlify.impl.server.ServerPolicies; +import dev.isxander.controlify.impl.server.ServerPolicy; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.DebugDump; import dev.isxander.yacl3.api.*; import dev.isxander.yacl3.api.controller.*; import net.minecraft.ChatFormatting; @@ -71,8 +70,8 @@ public static Screen createGlobalSettingsScreen(Screen parent) { .binding(GlobalSettings.defaults().allowServerRumble, () -> globalSettings.allowServerRumble, v -> globalSettings.allowServerRumble = v) .controller(TickBoxControllerBuilder::create) .addListener((opt, val) -> { - ControlifyApi.get().getCurrentController() - .flatMap(ControllerEntity::rumble) + Controlify.instance().getCurrentController() + .flatMap(ControllerImpl::rumble) .ifPresent(rumble -> rumble.rumbleManager().clearEffects()); }) .build()) diff --git a/src/main/java/dev/isxander/controlify/impl/gui/screen/InputCaptureBehaviour.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/InputCaptureBehaviour.java new file mode 100644 index 000000000..13f89fc7d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/InputCaptureBehaviour.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.gui.screen; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.VirtualMouseMode; + +/// Physical input is handled by a dedicated subscription rather than GUI actions. +public final class InputCaptureBehaviour implements ScreenBehaviour { + public InputCaptureBehaviour(ScreenContext context) { + context.virtualMouse().setMode(VirtualMouseMode.DISABLED); + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + // Captured input must not also navigate the screen. + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + // Captured input must not also activate controls. + } +} diff --git a/src/main/java/dev/isxander/controlify/gui/screen/ProfileSelectionScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/ProfileSelectionScreen.java similarity index 84% rename from src/main/java/dev/isxander/controlify/gui/screen/ProfileSelectionScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/ProfileSelectionScreen.java index a77ac7bb5..83fedcd3a 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/ProfileSelectionScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/ProfileSelectionScreen.java @@ -4,19 +4,20 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.config.settings.device.DeviceSettings; -import dev.isxander.controlify.config.settings.profile.ProfileSettings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.screenop.ScreenControllerEventListener; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.MinecraftUtil; +package dev.isxander.controlify.impl.gui.screen; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.config.settings.device.DeviceSettings; +import dev.isxander.controlify.impl.config.settings.profile.ProfileSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.AbstractButton; @@ -43,7 +44,7 @@ import java.util.ArrayList; import java.util.List; -public class ProfileSelectionScreen extends Screen implements ScreenControllerEventListener { +public class ProfileSelectionScreen extends Screen { private static final int CARD_WIDTH = 128; private static final int CARD_HEIGHT = 104; private static final int CARD_SPACING = 6; @@ -99,8 +100,8 @@ protected void init() { ); scrollable.setScrollbarSpacing(0); //?} else { - /*ScrollableLayout scrollable = new ScrollableLayout(this.minecraft, scrollContent, this.layout.getContentHeight()); - *///?} + //ScrollableLayout scrollable = new ScrollableLayout(this.minecraft, scrollContent, this.layout.getContentHeight()); + //?} scrollable.setMinWidth(columns * CARD_WIDTH + Math.max(0, columns - 1) * CARD_SPACING); scrollable.setMaxHeight(this.layout.getContentHeight()); scrollable.setMinHeight(this.layout.getContentHeight()); @@ -146,10 +147,10 @@ protected void init() { ); this.layout.addToFooter(footer); - ButtonGuideApi.addGuideToButton(this.rememberButton, ControlifyBindings.GUI_PRESS, ButtonGuidePredicate.always()); - ButtonGuideApi.addGuideToButton(this.onceButton, ControlifyBindings.GUI_ABSTRACT_ACTION_1, ButtonGuidePredicate.always()); - ButtonGuideApi.addGuideToButton(this.deleteButton, ControlifyBindings.GUI_ABSTRACT_ACTION_2, ButtonGuidePredicate.always()); - ButtonGuideApi.addGuideToButton(backButton, ControlifyBindings.GUI_BACK, ButtonGuidePredicate.always()); + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get(this).buttonGuide().addGuideToButton(this.rememberButton, ControlifyActions.GUI_PRESS, ButtonGuidePredicate.always()); + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get(this).buttonGuide().addGuideToButton(this.onceButton, ControlifyActions.GUI_ABSTRACT_ACTION_1, ButtonGuidePredicate.always()); + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get(this).buttonGuide().addGuideToButton(this.deleteButton, ControlifyActions.GUI_ABSTRACT_ACTION_2, ButtonGuidePredicate.always()); + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get(this).buttonGuide().addGuideToButton(backButton, ControlifyActions.GUI_BACK, ButtonGuidePredicate.always()); this.layout.visitWidgets(this::addRenderableWidget); this.repositionElements(); @@ -199,17 +200,6 @@ public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouse ); } - @Override - public void onControllerInput(ControllerEntity controller) { - if (this.selectedProfileIndex >= 0 - && ControlifyBindings.GUI_ABSTRACT_ACTION_1.on(controller).justPressed()) { - this.switchProfile(this.selectedProfileIndex, false); - } else if (this.deleteButton.active - && ControlifyBindings.GUI_ABSTRACT_ACTION_2.on(controller).justPressed()) { - this.confirmDeleteProfile(); - } - } - @Override public void onClose() { MinecraftUtil.setScreen(this.parent); @@ -239,7 +229,7 @@ private void updateActionButtons() { } if (this.deleteButton != null) { this.deleteButton.active = hasProfile - && this.selectedProfileIndex != Controlify.instance().config().getActiveProfileIndex(); + && this.selectedProfileIndex != Controlify.instance().config().getActiveProfileIndex(); } } @@ -270,7 +260,7 @@ private void createProfile() { private void confirmDeleteProfile() { if (this.selectedProfileIndex < 0 - || this.selectedProfileIndex == Controlify.instance().config().getActiveProfileIndex()) { + || this.selectedProfileIndex == Controlify.instance().config().getActiveProfileIndex()) { return; } @@ -326,10 +316,8 @@ private ControllerDisplay controllerDisplay(ProfileSettings profile) { ); } - ControllerEntity connected = Controlify.instance().getControllerManager() - .flatMap(manager -> manager.getConnectedControllers().stream() - .filter(controller -> profile.controllerUid.equals(controller.uid())) - .findFirst()) + ControllerImpl connected = Controlify.instance().getControllerManager() + .flatMap(manager -> manager.getControllerByUid(profile.controllerUid)) .orElse(null); if (connected != null) { return new ControllerDisplay( @@ -489,4 +477,25 @@ protected void updateWidgetNarration(@NonNull NarrationElementOutput output) { private record ControllerDisplay(Component name, Identifier icon) { } + + public static final class Behaviour implements ScreenBehaviour { + private final ProfileSelectionScreen screen; + + public Behaviour(ScreenContext context) { + this.screen = context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (this.screen.selectedProfileIndex >= 0 + && ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + this.screen.switchProfile(this.screen.selectedProfileIndex, false); + } else if (this.screen.deleteButton.active + && ControlifyActions.GUI_ABSTRACT_ACTION_2.on(controller).pulse().consumeAllPulses()) { + this.screen.confirmDeleteProfile(); + } + + next.run(); + } + } } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialItems.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialItems.java similarity index 75% rename from src/main/java/dev/isxander/controlify/gui/screen/RadialItems.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/RadialItems.java index 2436f1db5..84a0ce179 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/RadialItems.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialItems.java @@ -4,15 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.bind.InputBindingSupplier; -import dev.isxander.controlify.api.bind.RadialIcon; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.bindings.RadialIconManager; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.utils.CUtil; +package dev.isxander.controlify.impl.gui.screen; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.gui.radial.RadialIcon; +import dev.isxander.controlify.impl.gui.radial.RadialIconManager; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; import net.minecraft.client.player.inventory.Hotbar; @@ -32,19 +32,19 @@ public final class RadialItems { public static final RadialMenuScreen.RadialItem EMPTY_ACTION = new RadialItemRecord(Component.empty(), RadialIcon.EMPTY, () -> false, RadialIconManager.EMPTY); - public static RadialMenuScreen.RadialItem[] createBindings(ControllerEntity controller) { + public static RadialMenuScreen.RadialItem[] createActions(ControllerImpl controller) { List actions = controller.settings().input.radialMenu.radialActions; RadialMenuScreen.RadialItem[] items = new RadialMenuScreen.RadialItem[actions.size()]; for (int i = 0; i < actions.size(); i++) { - items[i] = getItemForBinding(actions.get(i), controller); + items[i] = getItemForAction(actions.get(i), controller); } return items; } - public static List createBindingPages(ControllerEntity controller) { - RadialMenuScreen.RadialItem[] items = createBindings(controller); + public static List createActionPages(ControllerImpl controller) { + RadialMenuScreen.RadialItem[] items = createActions(controller); return RadialMenuPages.partition(Arrays.asList(items)).stream() .map(page -> page.toArray(RadialMenuScreen.RadialItem[]::new)) .toList(); @@ -59,7 +59,7 @@ public static RadialPage createGameModes() { items[3] = new GameModeItem(GameType.SPECTATOR); return new RadialPage( - Component.translatable("controlify.binding.controlify.game_mode_switcher"), + Component.translatable("controlify.action.controlify.game_mode_switcher"), items ); } @@ -133,22 +133,22 @@ public static RadialMenuScreen.RadialItem[] createHotbarItemSelect() { return items; } - public static RadialMenuScreen.RadialItem[] createDebug(ControllerEntity controller) { + public static RadialMenuScreen.RadialItem[] createDebug(ControllerImpl controller) { return List.of( - ControlifyBindings.TOGGLE_DEBUG_MENU, - ControlifyBindings.TOGGLE_DEBUG_MENU_FPS, - ControlifyBindings.TOGGLE_DEBUG_MENU_NET, - ControlifyBindings.TOGGLE_DEBUG_MENU_PROF, - ControlifyBindings.DEBUG_RELOAD_CHUNKS, - ControlifyBindings.DEBUG_TOGGLE_CHUNK_BORDERS, - ControlifyBindings.DEBUG_TOGGLE_ADVANCED_TOOLTIPS, - ControlifyBindings.DEBUG_TOGGLE_ENTITY_HITBOXES, - ControlifyBindings.DEBUG_RELOAD_RESOURCE_PACKS, - ControlifyBindings.DEBUG_CLEAR_CHAT, - ControlifyBindings.DEBUG_START_STOP_PROFILING + ControlifyActions.TOGGLE_DEBUG_MENU, + ControlifyActions.TOGGLE_DEBUG_MENU_FPS, + ControlifyActions.TOGGLE_DEBUG_MENU_NET, + ControlifyActions.TOGGLE_DEBUG_MENU_PROF, + ControlifyActions.DEBUG_RELOAD_CHUNKS, + ControlifyActions.DEBUG_TOGGLE_CHUNK_BORDERS, + ControlifyActions.DEBUG_TOGGLE_ADVANCED_TOOLTIPS, + ControlifyActions.DEBUG_TOGGLE_ENTITY_HITBOXES, + ControlifyActions.DEBUG_RELOAD_RESOURCE_PACKS, + ControlifyActions.DEBUG_CLEAR_CHAT, + ControlifyActions.DEBUG_START_STOP_PROFILING ).stream() - .map(InputBindingSupplier::bindId) - .map(id -> getItemForBinding(id, controller)) + .map(ActionAccessor::actionId) + .map(id -> getItemForAction(id, controller)) .toArray(RadialMenuScreen.RadialItem[]::new); } @@ -179,19 +179,24 @@ private static RadialIcon getIconForItem(ItemStack stack) { return model != null ? RadialIcon.model(model) : RadialIcon.EMPTY; } - private static RadialMenuScreen.RadialItem getItemForBinding(Identifier id, ControllerEntity controller) { - InputBinding binding = controller.input().orElseThrow().getBinding(id); + private static RadialMenuScreen.RadialItem getItemForAction(Identifier id, ControllerImpl controller) { + var action = controller.input().getAction(id); + RadialIconManager radialIconManager = Controlify.instance().radialIconManager(); - if (binding == null || !RadialIconManager.INSTANCE.isRadialCandidate(binding)) { - CUtil.LOGGER.warn("Binding {} does not exist or is not a radial candidate", binding); + if (action == null || !radialIconManager.isRadialCandidate(action.spec().id())) { + CUtil.LOGGER.warn("Action {} does not exist or is not a radial candidate", action); return EMPTY_ACTION; } return new RadialItemRecord( - binding.name(), - RadialIconManager.INSTANCE.getIcon(binding), + action.spec().name(), + radialIconManager.getIcon(action.spec().id()), () -> { - binding.fakePress(); + var currentAction = controller.input().getAction(id); + if (currentAction == null) { + return false; + } + currentAction.state().firePulse(); return true; }, id @@ -209,7 +214,9 @@ public boolean playAction() { @Override public boolean equals(Object obj) { - if (this == obj) return true; + if (this == obj) { + return true; + } if (obj instanceof RadialItemRecord record) { return id.equals(record.id); } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditModel.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuEditModel.java similarity index 97% rename from src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditModel.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuEditModel.java index c9f81df74..2c0c18b77 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditModel.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuEditModel.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; import net.minecraft.resources.Identifier; import org.jetbrains.annotations.Nullable; diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuEditScreen.java similarity index 80% rename from src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuEditScreen.java index bca14447d..bf7ab64c2 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuEditScreen.java @@ -4,23 +4,22 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.bind.RadialIcon; -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.bindings.RadialIconExtractor; -import dev.isxander.controlify.bindings.RadialIconManager; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.haptic.HapticEffects; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.controlify.virtualmouse.VirtualMouseBehaviour; +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.VirtualMouseMode; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.gui.radial.RadialIcon; +import dev.isxander.controlify.impl.gui.radial.RadialIconExtractor; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.haptic.HapticEffects; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.AbstractButton; @@ -33,8 +32,8 @@ import net.minecraft.client.gui.layouts.Layout; import net.minecraft.client.gui.layouts.LayoutSettings; import net.minecraft.client.gui.layouts.LinearLayout; -import net.minecraft.client.gui.narration.NarrationElementOutput; import net.minecraft.client.gui.narration.NarratedElementType; +import net.minecraft.client.gui.narration.NarrationElementOutput; import net.minecraft.client.gui.navigation.ScreenDirection; import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.input.InputWithModifiers; @@ -52,7 +51,7 @@ import java.util.function.Function; import java.util.stream.Collectors; -public class RadialMenuEditScreen extends Screen implements ScreenProcessorProvider { +public class RadialMenuEditScreen extends Screen { private static final int COLUMN_GAP = 8; private static final int COLUMN_PADDING = 4; private static final int COLUMN_HEADER_HEIGHT = 24; @@ -62,11 +61,10 @@ public class RadialMenuEditScreen extends Screen implements ScreenProcessorProvi private static final int ROW_GAP = 2; private final Screen parent; - private final ControllerEntity controller; + private final ControllerImpl controller; private final RadialMenuEditModel model; - private final List candidates; - private final Map candidatesById; - private final Processor processor = new Processor(this); + private final List candidates; + private final Map candidatesById; private HeaderAndFooterLayout layout; private ContainerEventHandler availableContainer; @@ -76,16 +74,18 @@ public class RadialMenuEditScreen extends Screen implements ScreenProcessorProvi private Identifier focusAfterRebuild; private int equippedInsertionIndex; - public RadialMenuEditScreen(Screen parent, ControllerEntity controller) { + public RadialMenuEditScreen(Screen parent, ControllerImpl controller) { super(Component.translatable("controlify.radial_menu.editor.title")); this.parent = parent; this.controller = controller; this.model = new RadialMenuEditModel(controller.settings().input.radialMenu.radialActions); - this.candidates = controller.input().orElseThrow().getAllBindings().stream() - .filter(RadialIconManager.INSTANCE::isRadialCandidate) + this.candidates = controller.input().getAllActions().stream() + .map(action -> action) + .filter(action -> action.spec().shouldExpose().test(controller)) + .filter(action -> Controlify.instance().radialIconManager().isRadialCandidate(action.spec().id())) .toList(); this.candidatesById = candidates.stream().collect(Collectors.toMap( - InputBinding::id, + a -> a.spec().id(), Function.identity(), (first, ignored) -> first, LinkedHashMap::new @@ -115,7 +115,7 @@ protected void init() { Button done = footer.addChild(Button.builder(CommonComponents.GUI_DONE, _ -> commitAndClose()) .width(FOOTER_BUTTON_WIDTH) .build()); - ButtonGuideApi.addGuideToButton(done, ControlifyBindings.GUI_BACK, ButtonGuidePredicate.always()); + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get(this).buttonGuide().addGuideToButton(done, ControlifyActions.GUI_BACK, ButtonGuidePredicate.always()); layout.addToFooter(footer); layout.visitWidgets(this::addRenderableWidget); @@ -124,7 +124,7 @@ protected void init() { private LinearLayout createAvailableColumn(int width) { LinearLayout column = LinearLayout.vertical().spacing(4); - long availableCount = candidates.stream().filter(binding -> !model.actions().contains(binding.id())).count(); + long availableCount = candidates.stream().filter(action -> !model.actions().contains(action.spec().id())).count(); column.addChild(new ColumnHeader( width, Component.translatable("controlify.radial_menu.editor.available"), @@ -142,14 +142,14 @@ private LinearLayout createAvailableColumn(int width) { )); } - Map> grouped = candidates.stream() - .filter(binding -> !model.actions().contains(binding.id())) - .collect(Collectors.groupingBy(InputBinding::category, LinkedHashMap::new, Collectors.toList())); + Map> grouped = candidates.stream() + .filter(action -> !model.actions().contains(action.spec().id())) + .collect(Collectors.groupingBy(a -> a.spec().category(), LinkedHashMap::new, Collectors.toList())); grouped.forEach((category, bindings) -> { content.addChild(new SectionDivider(entryWidth, category, false)); - for (InputBinding binding : bindings) { - ActionEntry entry = new ActionEntry(binding.id(), binding.name(), false, -1, entryWidth); - availableEntries.put(binding.id(), entry); + for (Action action : bindings) { + ActionEntry entry = new ActionEntry(action.spec().id(), action.spec().name(), false, -1, entryWidth); + availableEntries.put(action.spec().id(), entry); content.addChild(entry); } }); @@ -162,7 +162,9 @@ private LinearLayout createAvailableColumn(int width) { ScrollableLayout scrollable = createScrollable(padded(content), width); scrollable.visitWidgets(widget -> { - if (widget instanceof ContainerEventHandler container) availableContainer = container; + if (widget instanceof ContainerEventHandler container) { + availableContainer = container; + } }); column.addChild(scrollable); return column; @@ -190,10 +192,10 @@ private LinearLayout createEquippedColumn(int width) { } Identifier id = actions.get(index); - InputBinding binding = candidatesById.get(id); - Component name = binding == null + Action action = candidatesById.get(id); + Component name = action == null ? Component.translatable("controlify.radial_menu.editor.missing", id.toString()).withStyle(ChatFormatting.RED) - : binding.name(); + : action.spec().name(); ActionEntry entry = new ActionEntry(id, name, true, index, entryWidth); equippedEntries.put(id, entry); content.addChild(entry); @@ -216,7 +218,9 @@ private LinearLayout createEquippedColumn(int width) { ScrollableLayout scrollable = createScrollable(padded(content), width); scrollable.visitWidgets(widget -> { - if (widget instanceof ContainerEventHandler container) equippedContainer = container; + if (widget instanceof ContainerEventHandler container) { + equippedContainer = container; + } }); column.addChild(scrollable); return column; @@ -240,8 +244,8 @@ private ScrollableLayout createScrollable(Layout content, int width) { ); scrollable.setScrollbarSpacing(0); //?} else { - /*ScrollableLayout scrollable = new ScrollableLayout(minecraft, content, height); - *///?} + //ScrollableLayout scrollable = new ScrollableLayout(minecraft, content, height); + //?} scrollable.setMinWidth(width); scrollable.setMaxHeight(height); scrollable.setMinHeight(height); @@ -251,7 +255,9 @@ private ScrollableLayout createScrollable(Layout content, int width) { private void activate(Identifier id, boolean equipped) { if (!model.isCarrying()) { model.pickUp(id); - if (equipped) equippedInsertionIndex = model.actions().indexOf(id); + if (equipped) { + equippedInsertionIndex = model.actions().indexOf(id); + } focusAfterRebuild = id; rebuildWidgets(); } else if (Objects.equals(id, model.carried())) { @@ -278,7 +284,9 @@ private void activate(Identifier id, boolean equipped) { private boolean moveCarried(ScreenDirection direction) { Identifier carried = model.carried(); - if (carried == null) return false; + if (carried == null) { + return false; + } boolean changed = switch (direction) { case LEFT -> { @@ -294,7 +302,9 @@ private boolean moveCarried(ScreenDirection direction) { }; if (changed) { - if (model.carriedIsEquipped()) equippedInsertionIndex = model.actions().indexOf(carried); + if (model.carriedIsEquipped()) { + equippedInsertionIndex = model.actions().indexOf(carried); + } focusAfterRebuild = carried; rebuildWidgets(); } @@ -418,14 +428,11 @@ private void commitAndClose() { MinecraftUtil.setScreen(parent); } - @Override - public ScreenProcessor screenProcessor() { - return processor; - } - private Component trim(Component text, int maxWidth) { String value = text.getString(); - if (font.width(value) <= maxWidth) return text; + if (font.width(value) <= maxWidth) { + return text; + } int ellipsisWidth = font.width("..."); return Component.literal(font.plainSubstrByWidth(value, Math.max(0, maxWidth - ellipsisWidth)) + "..."); @@ -545,10 +552,10 @@ private ActionEntry(Identifier id, Component name, boolean equipped, int positio this.equipped = equipped; this.position = position; - InputBinding binding = candidatesById.get(id); - this.icon = binding == null + Action action = candidatesById.get(id); + this.icon = action == null ? RadialIcon.EMPTY - : RadialIconManager.INSTANCE.getIcon(binding); + : Controlify.instance().radialIconManager().getIcon(action.spec().id()); } @Override @@ -635,50 +642,55 @@ protected void updateWidgetNarration(@NonNull NarrationElementOutput output) { } } - public static class Processor extends ScreenProcessor { - public Processor(RadialMenuEditScreen screen) { - super(screen); + public static final class Behaviour implements ScreenBehaviour { + private final RadialMenuEditScreen screen; + private final ScreenContext context; + + public Behaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + context.virtualMouse().setMode(VirtualMouseMode.DISABLED); } @Override - protected void handleComponentNavigation(ControllerEntity controller) { - if (!screen.model.isCarrying()) { - super.handleComponentNavigation(controller); + public void handleNavigation(@NonNull Controller input, @NonNull Runnable next) { + var controller = (ControllerImpl) input; + if (!this.screen.model.isCarrying()) { + next.run(); return; } boolean moved = false; - if (ControlifyBindings.GUI_NAVI_LEFT.on(controller).justPressed()) { - moved = screen.moveCarried(ScreenDirection.LEFT); - } else if (ControlifyBindings.GUI_NAVI_RIGHT.on(controller).justPressed()) { - moved = screen.moveCarried(ScreenDirection.RIGHT); - } else if (ControlifyBindings.GUI_NAVI_UP.on(controller).justPressed()) { - moved = screen.moveCarried(ScreenDirection.UP); - } else if (ControlifyBindings.GUI_NAVI_DOWN.on(controller).justPressed()) { - moved = screen.moveCarried(ScreenDirection.DOWN); + while (ControlifyActions.GUI_NAVI_LEFT.on(controller).pulse().consumePulse()) { + moved |= this.screen.moveCarried(ScreenDirection.LEFT); + } + while (ControlifyActions.GUI_NAVI_RIGHT.on(controller).pulse().consumePulse()) { + moved |= this.screen.moveCarried(ScreenDirection.RIGHT); + } + while (ControlifyActions.GUI_NAVI_UP.on(controller).pulse().consumePulse()) { + moved |= this.screen.moveCarried(ScreenDirection.UP); + } + while (ControlifyActions.GUI_NAVI_DOWN.on(controller).pulse().consumePulse()) { + moved |= this.screen.moveCarried(ScreenDirection.DOWN); } if (moved) { - playFocusChangeSound(); + context.playFocusChangeSound(); controller.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE)); } } @Override - protected void handleButtons(ControllerEntity controller) { - if (screen.model.isCarrying()) { - if (ControlifyBindings.GUI_PRESS.on(controller).guiPressed().get()) { - screen.dropCarried(); - playClackSound(); + public void handleInteraction(@NonNull Controller input, @NonNull Runnable next) { + var controller = (ControllerImpl) input; + if (this.screen.model.isCarrying()) { + if (ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses()) { + this.screen.dropCarried(); + context.playClickSound(); return; } } - super.handleButtons(controller); - } - - @Override - public VirtualMouseBehaviour virtualMouseBehaviour() { - return VirtualMouseBehaviour.DISABLED; + next.run(); } } } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuPages.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuPages.java similarity index 93% rename from src/main/java/dev/isxander/controlify/gui/screen/RadialMenuPages.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuPages.java index 6ae415e11..442eb276f 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuPages.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuPages.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuScreen.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuScreen.java similarity index 64% rename from src/main/java/dev/isxander/controlify/gui/screen/RadialMenuScreen.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuScreen.java index 838ce9814..f78cc02ab 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuScreen.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/RadialMenuScreen.java @@ -4,24 +4,26 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; - -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.api.bind.RadialIcon; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.bindings.RadialIconExtractor; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.haptic.HapticEffects; -import dev.isxander.controlify.gui.screen.RadialItems.RadialPage; -import dev.isxander.controlify.screenop.ScreenControllerEventListener; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.sound.ControlifyClientSounds; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.controlify.utils.animation.api.Animation; -import dev.isxander.controlify.utils.animation.api.EasingFunction; -import dev.isxander.controlify.virtualmouse.VirtualMouseBehaviour; +package dev.isxander.controlify.impl.gui.screen; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.VirtualMouseMode; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.gui.radial.RadialIcon; +import dev.isxander.controlify.impl.gui.radial.RadialIconExtractor; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.haptic.HapticEffects; +import dev.isxander.controlify.impl.gui.screen.RadialItems.RadialPage; +import dev.isxander.controlify.impl.sound.ControlifyClientSounds; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.impl.utils.animation.api.Animation; +import dev.isxander.controlify.impl.utils.animation.api.EasingFunction; +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.TextAlignment; import net.minecraft.client.gui.components.MultiLineLabel; @@ -45,13 +47,13 @@ import java.util.Collections; import java.util.List; -public class RadialMenuScreen extends Screen implements ScreenControllerEventListener, ScreenProcessorProvider { - private final ControllerEntity controller; +public class RadialMenuScreen extends Screen { + private final ControllerImpl controller; private final @Nullable Screen parent; private final Component footerText; private final List userPages; private final List bonusPages; - private final @Nullable InputBinding openBind; + private final @Nullable ActionAccessor openAction; private RadialButton[] buttons = new RadialButton[0]; private float radialRadius; @@ -60,25 +62,23 @@ public class RadialMenuScreen extends Screen implements ScreenControllerEventLis private int idleTicks; private final int idleTicksTimeout; - private final Processor processor = new Processor(this); - /** * Creates a single-page radial menu. Existing special-purpose radial menus use this path. */ - public RadialMenuScreen(ControllerEntity controller, InputBinding openBind, RadialItem[] items, Component text, @Nullable Screen parent) { - this(controller, openBind, Collections.singletonList(items), List.of(), text, parent); + public RadialMenuScreen(ControllerImpl controller, ActionAccessor openAction, RadialItem[] items, Component text, @Nullable Screen parent) { + this(controller, openAction, Collections.singletonList(items), List.of(), text, parent); } /** * Creates a paged radial menu. Page boundaries are a presentation concern and are not persisted. */ - public RadialMenuScreen(ControllerEntity controller, @Nullable InputBinding openBind, List pages, Component text, @Nullable Screen parent) { - this(controller, openBind, pages, List.of(), text, parent); + public RadialMenuScreen(ControllerImpl controller, @Nullable ActionAccessor openAction, List pages, Component text, @Nullable Screen parent) { + this(controller, openAction, pages, List.of(), text, parent); } public RadialMenuScreen( - ControllerEntity controller, - @Nullable InputBinding openBind, + ControllerImpl controller, + @Nullable ActionAccessor openAction, List userPages, List bonusPages, Component text, @@ -95,8 +95,8 @@ public RadialMenuScreen( .toList(); this.pageIndex = this.bonusPages.size(); this.parent = parent; - this.idleTicksTimeout = controller.input().orElseThrow().settings().radialMenu.radialButtonFocusTimeoutTicks; - this.openBind = openBind; + this.idleTicksTimeout = controller.input().settings().radialMenu.radialButtonFocusTimeoutTicks; + this.openAction = openAction; } @Override @@ -118,7 +118,7 @@ private void buildCurrentPage() { int centerX = this.width / 2; int centerY = this.height / 2; - float buttonDiameter = (float)Math.sqrt(32 * 32 + 32 * 32) + 8; + float buttonDiameter = (float) Math.sqrt(32 * 32 + 32 * 32) + 8; float circumference = buttonDiameter * items.length; radialRadius = Math.max(circumference / Mth.TWO_PI, 43); @@ -138,7 +138,9 @@ private void buildCurrentPage() { } private void changePage(int direction) { - if (pageCount() <= 1) return; + if (pageCount() <= 1) { + return; + } pageIndex = Math.floorMod(pageIndex + direction, pageCount()); buildCurrentPage(); @@ -146,33 +148,47 @@ private void changePage(int direction) { controller.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE)); } - @Override - public void onControllerInput(ControllerEntity controller) { - if (this.controller != controller) return; + private void onControllerInput(@NonNull ControllerImpl controller) { + if (this.controller != controller) { + return; + } - if (openBind == null) { - if (ControlifyBindings.GUI_BACK.on(controller).justPressed()) onClose(); + if (openAction == null) { + if (ControlifyActions.GUI_BACK.on(controller).pulse().consumeAllPulses()) { + onClose(); + } return; } - if (!openBind.digitalNow()) { - if (selectedButton >= 0 && selectedButton < buttons.length && buttons[selectedButton].invoke()) { - playClickSound(); + if (!openAction.on(controller).latch().isLatchActive()) { + if (selectedButton >= 0 && selectedButton < buttons.length) { + // force the invocation to happen next tick after the + // radial screen has closed, so the in-game action layers are in effect + minecraft.schedule(() -> { + if (buttons[selectedButton].invoke()) { + playClickSound(); + } + }); + } onClose(); return; } - if (buttons.length == 0) return; + if (buttons.length == 0) { + + return; + + } - float x = ControlifyBindings.RADIAL_AXIS_RIGHT.on(controller).analogueNow() - - ControlifyBindings.RADIAL_AXIS_LEFT.on(controller).analogueNow(); - float y = ControlifyBindings.RADIAL_AXIS_DOWN.on(controller).analogueNow() - - ControlifyBindings.RADIAL_AXIS_UP.on(controller).analogueNow(); - float threshold = controller.input().orElseThrow().settings().buttonActivationThreshold; + float x = ControlifyActions.RADIAL_AXIS_RIGHT.on(controller).continuous().getContinuous() + - ControlifyActions.RADIAL_AXIS_LEFT.on(controller).continuous().getContinuous(); + float y = ControlifyActions.RADIAL_AXIS_DOWN.on(controller).continuous().getContinuous() + - ControlifyActions.RADIAL_AXIS_UP.on(controller).continuous().getContinuous(); + float threshold = controller.input().settings().buttonActivationThreshold; if (Math.abs(x) >= threshold || Math.abs(y) >= threshold) { - float angle = Mth.wrapDegrees(Mth.RAD_TO_DEG * (float)Mth.atan2(y, x) - 90f) + 180f; + float angle = Mth.wrapDegrees(Mth.RAD_TO_DEG * (float) Mth.atan2(y, x) - 90f) + 180f; float each = 360f / buttons.length; int newSelected = Mth.floor((angle + each / 2f) / each) % buttons.length; @@ -185,7 +201,9 @@ public void onControllerInput(ControllerEntity controller) { for (int i = 0; i < buttons.length; i++) { boolean selected = i == selectedButton; buttons[i].setFocused(selected); - if (selected) setFocused(buttons[i]); + if (selected) { + setFocused(buttons[i]); + } } idleTicks = 0; } else { @@ -212,8 +230,8 @@ public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouse graphics.centeredText(font, page, width / 2, height - 25, -1); renderPageDots(graphics); - Component previous = ControlifyBindings.GUI_PREV_TAB.on(controller).inputGlyph(); - Component next = ControlifyBindings.GUI_NEXT_TAB.on(controller).inputGlyph(); + Component previous = ActionGlyphs.action(ControlifyActions.GUI_PREV_TAB.actionId()); + Component next = ActionGlyphs.action(ControlifyActions.GUI_NEXT_TAB.actionId()); int pageWidth = font.width(page); graphics.text(font, previous, width / 2 - pageWidth / 2 - font.width(previous) - 8, height - 25, -1); graphics.text(font, next, width / 2 + pageWidth / 2 + 8, height - 25, -1); @@ -221,13 +239,17 @@ public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouse } private RadialItem[] getCurrentPageItems() { - if (pageIndex < bonusPages.size()) return bonusPages.get(pageIndex).items(); + if (pageIndex < bonusPages.size()) { + return bonusPages.get(pageIndex).items(); + } return userPages.get(pageIndex - bonusPages.size()); } private Component getPageTitle() { - if (pageIndex < bonusPages.size()) return bonusPages.get(pageIndex).name(); + if (pageIndex < bonusPages.size()) { + return bonusPages.get(pageIndex).name(); + } return Component.translatable( "controlify.radial_menu.page", @@ -263,13 +285,9 @@ public boolean isPauseScreen() { return false; } - @Override - public ScreenProcessor screenProcessor() { - return processor; - } - - private void playClickSound() { - minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1f)); + private static void playClickSound() { + Minecraft.getInstance().getSoundManager() + .play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1f)); } public interface RadialItem { @@ -293,7 +311,7 @@ private RadialButton(RadialItem item, float x, float y) { setX(x); setY(y); this.item = item; - this.name = MultiLineLabel.create(font, item.name(), (int)(radialRadius * 2 - 32)); + this.name = MultiLineLabel.create(font, item.name(), (int) (radialRadius * 2 - 32)); } @Override @@ -323,12 +341,12 @@ private boolean invoke() { } private void setX(float x) { - this.x = (int)x; + this.x = (int) x; this.translateX = x - this.x; } private void setY(float y) { - this.y = (int)y; + this.y = (int) y; this.translateY = y - this.y; } @@ -358,23 +376,30 @@ public void updateNarration(NarrationElementOutput builder) { } } - public static class Processor extends ScreenProcessor { - public Processor(RadialMenuScreen screen) { - super(screen); - } + public static final class Behaviour implements ScreenBehaviour { + private final RadialMenuScreen screen; + private final ScreenContext context; - @Override - protected void handleTabNavigation(ControllerEntity controller) { - if (ControlifyBindings.GUI_NEXT_TAB.on(controller).justPressed()) { - screen.changePage(1); - } else if (ControlifyBindings.GUI_PREV_TAB.on(controller).justPressed()) { - screen.changePage(-1); - } + public Behaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + context.virtualMouse().setMode(VirtualMouseMode.DISABLED); } @Override - public VirtualMouseBehaviour virtualMouseBehaviour() { - return VirtualMouseBehaviour.DISABLED; + public void handleNavigation(@NonNull Controller input, @NonNull Runnable next) { + var controller = (ControllerImpl) input; + this.screen.onControllerInput(controller); + if (MinecraftUtil.getScreen() != this.context.screen()) { + return; + } + while (ControlifyActions.GUI_NEXT_TAB.on(controller).pulse().consumePulse()) { + this.screen.changePage(1); + } + while (ControlifyActions.GUI_PREV_TAB.on(controller).pulse().consumePulse()) { + this.screen.changePage(-1); + } + next.run(); } } } diff --git a/src/main/java/dev/isxander/controlify/gui/screen/SteamDeckAlerts.java b/src/main/java/dev/isxander/controlify/impl/gui/screen/SteamDeckAlerts.java similarity index 96% rename from src/main/java/dev/isxander/controlify/gui/screen/SteamDeckAlerts.java rename to src/main/java/dev/isxander/controlify/impl/gui/screen/SteamDeckAlerts.java index ef4844534..a0efa0920 100644 --- a/src/main/java/dev/isxander/controlify/gui/screen/SteamDeckAlerts.java +++ b/src/main/java/dev/isxander/controlify/impl/gui/screen/SteamDeckAlerts.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.gui.screen; +package dev.isxander.controlify.impl.gui.screen; import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; diff --git a/src/main/java/dev/isxander/controlify/hid/ControllerHIDInfo.java b/src/main/java/dev/isxander/controlify/impl/hid/ControllerHIDInfo.java similarity index 78% rename from src/main/java/dev/isxander/controlify/hid/ControllerHIDInfo.java rename to src/main/java/dev/isxander/controlify/impl/hid/ControllerHIDInfo.java index 5435db2b3..8047d7b70 100644 --- a/src/main/java/dev/isxander/controlify/hid/ControllerHIDInfo.java +++ b/src/main/java/dev/isxander/controlify/impl/hid/ControllerHIDInfo.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.hid; +package dev.isxander.controlify.impl.hid; import com.google.common.primitives.Ints; -import dev.isxander.controlify.controller.id.ControllerType; +import dev.isxander.controlify.impl.controllermanager.ControllerType; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -29,10 +29,10 @@ public Optional createControllerUID(int controllerIndex) { md.update(Ints.toByteArray(hid.productId())); }); - String namespace = type().namespace().toString(); - if ("controlify".equals(type().namespace().getNamespace())) { + String namespace = type().id().toString(); + if ("controlify".equals(type().id().getNamespace())) { // maintains backwards compatibility - namespace = type().namespace().getPath(); + namespace = type().id().getPath(); } md.update(namespace.getBytes()); diff --git a/src/main/java/dev/isxander/controlify/hid/HIDDevice.java b/src/main/java/dev/isxander/controlify/impl/hid/HIDDevice.java similarity index 87% rename from src/main/java/dev/isxander/controlify/hid/HIDDevice.java rename to src/main/java/dev/isxander/controlify/impl/hid/HIDDevice.java index e6c5697a0..057d6ba9e 100644 --- a/src/main/java/dev/isxander/controlify/hid/HIDDevice.java +++ b/src/main/java/dev/isxander/controlify/impl/hid/HIDDevice.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.hid; +package dev.isxander.controlify.impl.hid; public record HIDDevice(HIDID hidid, String path) { public int vendorId() { diff --git a/src/main/java/dev/isxander/controlify/hid/HIDID.java b/src/main/java/dev/isxander/controlify/impl/hid/HIDID.java similarity index 85% rename from src/main/java/dev/isxander/controlify/hid/HIDID.java rename to src/main/java/dev/isxander/controlify/impl/hid/HIDID.java index bcfb3db8c..84f90c292 100644 --- a/src/main/java/dev/isxander/controlify/hid/HIDID.java +++ b/src/main/java/dev/isxander/controlify/impl/hid/HIDID.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.hid; +package dev.isxander.controlify.impl.hid; import com.mojang.serialization.Codec; -import dev.isxander.controlify.utils.codec.CExtraCodecs; +import dev.isxander.controlify.impl.utils.codec.CExtraCodecs; import org.jetbrains.annotations.NotNull; import java.util.HexFormat; diff --git a/src/main/java/dev/isxander/controlify/ingame/ControllerPlayerMovement.java b/src/main/java/dev/isxander/controlify/impl/ingame/ControllerPlayerMovement.java similarity index 73% rename from src/main/java/dev/isxander/controlify/ingame/ControllerPlayerMovement.java rename to src/main/java/dev/isxander/controlify/impl/ingame/ControllerPlayerMovement.java index c450a3c43..86f063bc5 100644 --- a/src/main/java/dev/isxander/controlify/ingame/ControllerPlayerMovement.java +++ b/src/main/java/dev/isxander/controlify/impl/ingame/ControllerPlayerMovement.java @@ -4,27 +4,27 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.ingame; +package dev.isxander.controlify.impl.ingame; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.Minecraft; +import net.minecraft.client.player.ClientInput; import net.minecraft.client.player.KeyboardInput; import net.minecraft.client.player.LocalPlayer; +import net.minecraft.world.entity.player.Input; import net.minecraft.world.phys.Vec2; import org.jetbrains.annotations.Nullable; -import net.minecraft.client.player.ClientInput; -import net.minecraft.world.entity.player.Input; public class ControllerPlayerMovement extends ClientInput { - private final ControllerEntity controller; + private final ControllerImpl controller; private final LocalPlayer player; private boolean wasFlying, wasPassenger; - public ControllerPlayerMovement(ControllerEntity controller, LocalPlayer player) { + public ControllerPlayerMovement(ControllerImpl controller, LocalPlayer player) { this.controller = controller; this.player = player; } @@ -39,13 +39,13 @@ public void tick() { return; } - float forwardImpulse = ControlifyBindings.WALK_FORWARD.on(controller).analogueNow() - - ControlifyBindings.WALK_BACKWARD.on(controller).analogueNow(); - float leftImpulse = ControlifyBindings.WALK_LEFT.on(controller).analogueNow() - - ControlifyBindings.WALK_RIGHT.on(controller).analogueNow(); + float forwardImpulse = ControlifyActions.WALK_FORWARD.on(controller).continuous().getContinuous() + - ControlifyActions.WALK_BACKWARD.on(controller).continuous().getContinuous(); + float leftImpulse = ControlifyActions.WALK_LEFT.on(controller).continuous().getContinuous() + - ControlifyActions.WALK_RIGHT.on(controller).continuous().getContinuous(); if (Controlify.instance().config().getSettings().globalSettings().shouldUseKeyboardMovement()) { - float threshold = controller.input().orElseThrow().settings().buttonActivationThreshold; + float threshold = controller.input().settings().buttonActivationThreshold; forwardImpulse = Math.abs(forwardImpulse) >= threshold ? Math.copySign(1, forwardImpulse) : 0; leftImpulse = Math.abs(leftImpulse) >= threshold ? Math.copySign(1, leftImpulse) : 0; @@ -62,20 +62,19 @@ public void tick() { this.setMoveVec(forwardImpulse, leftImpulse); // this over-complication is so exiting a GUI with the button still held doesn't trigger a jump. - InputBinding jump = ControlifyBindings.JUMP.on(controller); - if (jump.justPressed()) - jumping = true; - if (!jump.digitalNow()) - jumping = false; + Action jump = ControlifyActions.JUMP.on(controller); + jumping = jump.pulse().consumeAllPulses() || jump.latch().isLatchActive(); - InputBinding sneak = ControlifyBindings.SNEAK.on(controller); + Action sneak = ControlifyActions.SNEAK.on(controller); if (player.getAbilities().flying || (player.isInWater() && !player.onGround()) || player.getVehicle() != null || !controller.settings().generic.toggleSneak) { - if (sneak.justPressed()) + if (sneak.pulse().consumeAllPulses()) { shiftKeyDown = true; - if (!sneak.digitalNow()) + } + if (!sneak.latch().isLatchActive()) { shiftKeyDown = false; + } } else { - if (sneak.justPressed()) { + while (sneak.pulse().consumePulse()) { shiftKeyDown = !shiftKeyDown; } } @@ -83,7 +82,7 @@ public void tick() { shiftKeyDown = false; } - boolean sprinting = ControlifyBindings.SPRINT.on(controller).digitalNow(); + boolean sprinting = ControlifyActions.SPRINT.on(controller).latch().isLatchActive(); this.keyPresses = new Input(up, down, left, right, jumping, shiftKeyDown, sprinting); @@ -108,8 +107,9 @@ private void setMoveVec(float forward, float left) { } public static void updatePlayerInput(@Nullable LocalPlayer player) { - if (player == null) + if (player == null) { return; + } if (shouldBeControllerInput()) { player.input = new DualInput( @@ -122,8 +122,9 @@ public static void updatePlayerInput(@Nullable LocalPlayer player) { } public static void ensureCorrectInput(@Nullable LocalPlayer player) { - if (player == null) + if (player == null) { return; + } if (shouldBeControllerInput() && player.input.getClass() == KeyboardInput.class) { updatePlayerInput(player); diff --git a/src/main/java/dev/isxander/controlify/ingame/DualInput.java b/src/main/java/dev/isxander/controlify/impl/ingame/DualInput.java similarity index 97% rename from src/main/java/dev/isxander/controlify/ingame/DualInput.java rename to src/main/java/dev/isxander/controlify/impl/ingame/DualInput.java index e2b6ea6ba..e03bb0611 100644 --- a/src/main/java/dev/isxander/controlify/ingame/DualInput.java +++ b/src/main/java/dev/isxander/controlify/impl/ingame/DualInput.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.ingame; +package dev.isxander.controlify.impl.ingame; import net.minecraft.util.Mth; import net.minecraft.world.phys.Vec2; diff --git a/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java b/src/main/java/dev/isxander/controlify/impl/ingame/InGameInputHandler.java similarity index 57% rename from src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java rename to src/main/java/dev/isxander/controlify/impl/ingame/InGameInputHandler.java index f62ceecd2..fff3641af 100644 --- a/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java +++ b/src/main/java/dev/isxander/controlify/impl/ingame/InGameInputHandler.java @@ -4,33 +4,32 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.ingame; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.ingameinput.LookInputModifier; -import dev.isxander.controlify.api.event.ControlifyEvents; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.config.settings.profile.GyroSettings; -import dev.isxander.controlify.config.settings.profile.InputSettings; -import dev.isxander.controlify.controller.gyro.GyroState; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.gyro.GyroButtonMode; -import dev.isxander.controlify.controller.gyro.GyroComponent; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.driver.steamdeck.SteamDeckDriver; -import dev.isxander.controlify.gui.screen.RadialItems; -import dev.isxander.controlify.gui.screen.RadialItems.RadialPage; -import dev.isxander.controlify.gui.screen.RadialMenuScreen; +package dev.isxander.controlify.impl.ingame; + +import dev.isxander.controlify.api.ingame.LookInputModifier; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.config.settings.profile.GyroSettings; +import dev.isxander.controlify.impl.config.settings.profile.InputSettings; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.gyro.GyroButtonMode; +import dev.isxander.controlify.impl.controller.gyro.GyroComponentImpl; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.impl.driver.steamdeck.SteamDeckDriver; +import dev.isxander.controlify.impl.gui.screen.RadialItems.RadialPage; +import dev.isxander.controlify.impl.gui.screen.RadialItems; +import dev.isxander.controlify.impl.gui.screen.RadialMenuScreen; +import dev.isxander.controlify.impl.server.ServerPolicies; +import dev.isxander.controlify.impl.utils.ControllerUtils; +import dev.isxander.controlify.impl.utils.DebugOverlayHelper; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.impl.utils.animation.api.Animation; +import dev.isxander.controlify.impl.utils.animation.api.EasingFunction; +import dev.isxander.controlify.impl.utils.event.EventBus; import dev.isxander.controlify.mixins.feature.steamdeck.ScreenshotAccessor; -import dev.isxander.controlify.server.ServerPolicies; -import dev.isxander.controlify.utils.ControllerUtils; -import dev.isxander.controlify.utils.DebugOverlayHelper; -import dev.isxander.controlify.utils.HoldRepeatHelper; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.controlify.utils.animation.api.Animation; -import dev.isxander.controlify.utils.animation.api.EasingFunction; import net.minecraft.client.CameraType; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.ChatComponent; import net.minecraft.client.Screenshot; import net.minecraft.client.gui.screens.inventory.InventoryScreen; import net.minecraft.client.player.ClientInput; @@ -41,52 +40,49 @@ import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket; import net.minecraft.server.commands.GameModeCommand; import net.minecraft.util.Mth; -import net.minecraft.world.InteractionHand; +//? if <26.3 +//import net.minecraft.world.InteractionHand; import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.entity.player.Player; import net.minecraft.world.phys.Vec2; import net.minecraft.world.phys.Vec3; import org.joml.Vector2d; import org.joml.Vector2f; +import org.joml.Vector3f; import java.io.File; -import java.util.Objects; import java.util.ArrayList; import java.util.List; +import java.util.Objects; public class InGameInputHandler { - private final ControllerEntity controller; + public static final class Events { + public static final EventBus LOOK_INPUT_MODIFIER = new EventBus<>(); + + private Events() { + } + } + + private final ControllerImpl controller; private final Controlify controlify; private final Minecraft minecraft; - private double lookInputX, lookInputY; // in degrees per tick - private final GyroState gyroInput = new GyroState(); private boolean gyroToggledOn; private boolean wasAiming; private Animation flickAnimation; private boolean shouldShowPlayerList; - private final HoldRepeatHelper dropRepeatHelper; - private boolean dropRepeating; - - private final HoldRepeatHelper hotbarNextRepeatHelper; - private final HoldRepeatHelper hotbarPrevRepeatHelper; - - public InGameInputHandler(ControllerEntity controller) { + public InGameInputHandler(ControllerImpl controller) { this.controller = controller; this.minecraft = Minecraft.getInstance(); this.controlify = Controlify.instance(); - this.dropRepeatHelper = new HoldRepeatHelper(20, 1); - this.hotbarNextRepeatHelper = new HoldRepeatHelper(10, 4); - this.hotbarPrevRepeatHelper = new HoldRepeatHelper(10, 4); this.gyroToggledOn = false; } - public void inputTick() { + public void fixedUpdate() { boolean isController = ControllerPlayerMovement.shouldBeControllerInput(); - handlePlayerLookInput(isController); ControllerPlayerMovement.ensureCorrectInput(minecraft.player); if (isController) { @@ -96,66 +92,56 @@ public void inputTick() { } protected void handleKeybinds() { - if (MinecraftUtil.getScreen() != null) + if (MinecraftUtil.getScreen() != null) { return; + } - if (ControlifyBindings.PAUSE.on(controller).justPressed()) { + if (ControlifyActions.PAUSE.on(controller).pulse().consumeAllPulses()) { minecraft.pauseGame(false); } if (minecraft.player != null) { Inventory inventory = minecraft.player.getInventory(); - if (hotbarNextRepeatHelper.shouldAction(ControlifyBindings.NEXT_SLOT.on(controller))) { - hotbarNextRepeatHelper.onNavigate(); - + while (ControlifyActions.NEXT_SLOT.on(controller).pulse().consumePulse()) { inventory.setSelectedSlot((inventory.getSelectedSlot() + 1) % Inventory.getSelectionSize()); } - if (hotbarPrevRepeatHelper.shouldAction(ControlifyBindings.PREV_SLOT.on(controller))) { - hotbarPrevRepeatHelper.onNavigate(); - + while (ControlifyActions.PREV_SLOT.on(controller).pulse().consumePulse()) { inventory.setSelectedSlot((inventory.getSelectedSlot() - 1 + Inventory.getSelectionSize()) % Inventory.getSelectionSize()); } var hotbarSlots = List.of( - ControlifyBindings.HOTBAR_SLOT_1, - ControlifyBindings.HOTBAR_SLOT_2, - ControlifyBindings.HOTBAR_SLOT_3, - ControlifyBindings.HOTBAR_SLOT_4, - ControlifyBindings.HOTBAR_SLOT_5, - ControlifyBindings.HOTBAR_SLOT_6, - ControlifyBindings.HOTBAR_SLOT_7, - ControlifyBindings.HOTBAR_SLOT_8, - ControlifyBindings.HOTBAR_SLOT_9 + ControlifyActions.HOTBAR_SLOT_1, + ControlifyActions.HOTBAR_SLOT_2, + ControlifyActions.HOTBAR_SLOT_3, + ControlifyActions.HOTBAR_SLOT_4, + ControlifyActions.HOTBAR_SLOT_5, + ControlifyActions.HOTBAR_SLOT_6, + ControlifyActions.HOTBAR_SLOT_7, + ControlifyActions.HOTBAR_SLOT_8, + ControlifyActions.HOTBAR_SLOT_9 ); for (int i = 0; i < hotbarSlots.size(); i++) { - if (hotbarSlots.get(i).on(controller).justPressed()) { + if (hotbarSlots.get(i).on(controller).pulse().consumeAllPulses()) { inventory.setSelectedSlot(i); } } if (!minecraft.player.isSpectator()) { - if (ControlifyBindings.DROP_STACK.on(controller).justPressed()) { + if (ControlifyActions.DROP_STACK.on(controller).pulse().consumeAllPulses()) { dropItem(minecraft.player, true); + ControlifyActions.DROP_INGAME.on(controller).pulse().consumeAllPulses(); } else { - if (ControlifyBindings.DROP_INGAME.on(controller).justPressed()) { - dropRepeating = true; - } else if (ControlifyBindings.DROP_INGAME.on(controller).justReleased()) { - dropRepeating = false; - } - - if (dropRepeating && dropRepeatHelper.shouldAction(ControlifyBindings.DROP_INGAME.on(controller))) { - if (dropItem(minecraft.player, false)) { - dropRepeatHelper.onNavigate(); - } + while (ControlifyActions.DROP_INGAME.on(controller).pulse().consumePulse()) { + dropItem(minecraft.player, false); } } - if (ControlifyBindings.SWAP_HANDS.on(controller).justPressed()) { + while (ControlifyActions.SWAP_HANDS.on(controller).pulse().consumePulse()) { minecraft.player.connection.send(new ServerboundPlayerActionPacket(ServerboundPlayerActionPacket.Action.SWAP_ITEM_WITH_OFFHAND, BlockPos.ZERO, Direction.DOWN)); } } - if (ControlifyBindings.INVENTORY.on(controller).justPressed()) { + if (ControlifyActions.INVENTORY.on(controller).pulse().consumeAllPulses()) { if (minecraft.gameMode.isServerControlledInventory()) { minecraft.player.sendOpenInventory(); } else { @@ -164,7 +150,7 @@ protected void handleKeybinds() { } } - if (ControlifyBindings.CHANGE_PERSPECTIVE.on(controller).justPressed()) { + while (ControlifyActions.CHANGE_PERSPECTIVE.on(controller).pulse().consumePulse()) { CameraType cameraType = minecraft.options.getCameraType(); minecraft.options.setCameraType(minecraft.options.getCameraType().cycle()); if (cameraType.isFirstPerson() != minecraft.options.getCameraType().isFirstPerson()) { @@ -175,63 +161,70 @@ protected void handleKeybinds() { //minecraft.levelRenderer.needsUpdate(); } } - if (ControlifyBindings.TOGGLE_HUD_VISIBILITY.on(controller).justPressed()) { + while (ControlifyActions.TOGGLE_HUD_VISIBILITY.on(controller).pulse().consumePulse()) { //? if >=26.2 { minecraft.gui.hud.toggle(); //?} else { - /*minecraft.options.hideGui = !minecraft.options.hideGui; - *///?} + //minecraft.options.hideGui = !minecraft.options.hideGui; + //?} } - if (ControlifyBindings.SHOW_PLAYER_LIST.on(controller).justPressed()) { + while (ControlifyActions.SHOW_PLAYER_LIST.on(controller).pulse().consumePulse()) { shouldShowPlayerList = !shouldShowPlayerList; } + if (ControlifyActions.OPEN_CHAT.on(controller).pulse().consumeAllPulses()) { + //? if >=26.2 { + minecraft.gui.openChatScreen(ChatComponent.ChatMethod.MESSAGE); + //?} else { + //minecraft.openChatScreen(ChatComponent.ChatMethod.MESSAGE); + //?} + } - if (ControlifyBindings.TOGGLE_DEBUG_MENU.on(controller).justPressed()) { + while (ControlifyActions.TOGGLE_DEBUG_MENU.on(controller).pulse().consumePulse()) { DebugOverlayHelper.toggleOverlay(); } - if (ControlifyBindings.TOGGLE_DEBUG_MENU_FPS.on(controller).justPressed()) { + while (ControlifyActions.TOGGLE_DEBUG_MENU_FPS.on(controller).pulse().consumePulse()) { DebugOverlayHelper.toggleFpsOverlay(); } - if (ControlifyBindings.TOGGLE_DEBUG_MENU_NET.on(controller).justPressed()) { + while (ControlifyActions.TOGGLE_DEBUG_MENU_NET.on(controller).pulse().consumePulse()) { DebugOverlayHelper.toggleNetworkOverlay(); } - if (ControlifyBindings.TOGGLE_DEBUG_MENU_PROF.on(controller).justPressed()) { + while (ControlifyActions.TOGGLE_DEBUG_MENU_PROF.on(controller).pulse().consumePulse()) { DebugOverlayHelper.toggleProfilerOverlay(); } - if (ControlifyBindings.DEBUG_RELOAD_CHUNKS.on(controller).justPressed()) { + if (ControlifyActions.DEBUG_RELOAD_CHUNKS.on(controller).pulse().consumeAllPulses()) { DebugOverlayHelper.reloadChunks(); } - if (ControlifyBindings.DEBUG_TOGGLE_CHUNK_BORDERS.on(controller).justPressed()) { + while (ControlifyActions.DEBUG_TOGGLE_CHUNK_BORDERS.on(controller).pulse().consumePulse()) { DebugOverlayHelper.toggleChunkBorders(); } - if (ControlifyBindings.DEBUG_TOGGLE_ADVANCED_TOOLTIPS.on(controller).justPressed()) { + while (ControlifyActions.DEBUG_TOGGLE_ADVANCED_TOOLTIPS.on(controller).pulse().consumePulse()) { DebugOverlayHelper.toggleAdvancedTooltips(); } - if (ControlifyBindings.DEBUG_TOGGLE_ENTITY_HITBOXES.on(controller).justPressed()) { + while (ControlifyActions.DEBUG_TOGGLE_ENTITY_HITBOXES.on(controller).pulse().consumePulse()) { DebugOverlayHelper.toggleEntityHitboxes(); } - if (ControlifyBindings.DEBUG_RELOAD_RESOURCE_PACKS.on(controller).justPressed()) { + if (ControlifyActions.DEBUG_RELOAD_RESOURCE_PACKS.on(controller).pulse().consumeAllPulses()) { DebugOverlayHelper.reloadResourcePacks(); } - if (ControlifyBindings.DEBUG_CLEAR_CHAT.on(controller).justPressed()) { + if (ControlifyActions.DEBUG_CLEAR_CHAT.on(controller).pulse().consumeAllPulses()) { DebugOverlayHelper.clearChat(); } - if (ControlifyBindings.DEBUG_START_STOP_PROFILING.on(controller).justPressed()) { + while (ControlifyActions.DEBUG_START_STOP_PROFILING.on(controller).pulse().consumePulse()) { DebugOverlayHelper.startStopProfiling(); } - if (ControlifyBindings.DEBUG_RADIAL.on(controller).justPressed()) { + if (ControlifyActions.DEBUG_RADIAL.on(controller).pulse().consumeAllPulses()) { MinecraftUtil.setScreen(new RadialMenuScreen( controller, - ControlifyBindings.DEBUG_RADIAL.on(controller), + ControlifyActions.DEBUG_RADIAL, RadialItems.createDebug(controller), Component.empty(), null )); } - if (ControlifyBindings.TAKE_SCREENSHOT.on(controller).justPressed()) { + if (ControlifyActions.TAKE_SCREENSHOT.on(controller).pulse().consumeAllPulses()) { // get file before it takes and writes the screenshot (which changes the next name) File screenshotFile = ScreenshotAccessor.controlify$invokeGetFile( new File(minecraft.gameDirectory, "screenshots") @@ -242,14 +235,14 @@ protected void handleKeybinds() { //? if >=26.2 { this.minecraft.gameRenderer.mainRenderTarget(), //?} else { - /*this.minecraft.getMainRenderTarget(), - *///?} + //this.minecraft.getMainRenderTarget(), + //?} component -> this.minecraft.execute(() -> { //? if >=26.2 { this.minecraft.gui.hud.getChat() //?} else { - /*this.minecraft.gui.getChat() - *///?} + //this.minecraft.gui.getChat() + //?} .addClientSystemMessage(component); // TODO: this currently does not work, yet to debug why not @@ -260,14 +253,14 @@ protected void handleKeybinds() { ); } - if (ControlifyBindings.PICK_BLOCK.on(controller).justPressed()) { + if (ControlifyActions.PICK_BLOCK.on(controller).pulse().consumeAllPulses()) { ((PickBlockAccessor) minecraft).controlify$pickBlock(); } - if (ControlifyBindings.PICK_BLOCK_NBT.on(controller).justPressed()) { + if (ControlifyActions.PICK_BLOCK_NBT.on(controller).pulse().consumeAllPulses()) { ((PickBlockAccessor) minecraft).controlify$pickBlockWithNbt(); } - if (ControlifyBindings.RADIAL_MENU.on(controller).justPressed()) { + if (ControlifyActions.RADIAL_MENU.on(controller).pulse().consumeAllPulses()) { var bonusPages = new ArrayList(); if (canSwitchGameMode()) { bonusPages.add(RadialItems.createGameModes()); @@ -275,28 +268,28 @@ protected void handleKeybinds() { MinecraftUtil.setScreen(new RadialMenuScreen( controller, - ControlifyBindings.RADIAL_MENU.on(controller), - RadialItems.createBindingPages(controller), + ControlifyActions.RADIAL_MENU, + RadialItems.createActionPages(controller), bonusPages, Component.translatable("controlify.radial_menu.configure_hint"), null )); } - if (ControlifyBindings.GAME_MODE_SWITCHER.on(controller).justPressed() && canSwitchGameMode()) { + if (ControlifyActions.GAME_MODE_SWITCHER.on(controller).pulse().consumeAllPulses() && canSwitchGameMode()) { MinecraftUtil.setScreen(new RadialMenuScreen( controller, - ControlifyBindings.GAME_MODE_SWITCHER.on(controller), + ControlifyActions.GAME_MODE_SWITCHER, RadialItems.createGameModes().items(), Component.empty(), null) ); } - if (ControlifyBindings.HOTBAR_SLOT_SELECT.on(controller).justPressed()) { + if (ControlifyActions.HOTBAR_SLOT_SELECT.on(controller).pulse().consumeAllPulses()) { MinecraftUtil.setScreen(new RadialMenuScreen( controller, - ControlifyBindings.HOTBAR_SLOT_SELECT.on(controller), + ControlifyActions.HOTBAR_SLOT_SELECT, RadialItems.createHotbarItemSelect(), Component.empty(), null @@ -304,19 +297,19 @@ protected void handleKeybinds() { } if (minecraft.player != null && minecraft.player.hasInfiniteMaterials()) { - if (ControlifyBindings.HOTBAR_LOAD_RADIAL.on(controller).justPressed()) { + if (ControlifyActions.HOTBAR_LOAD_RADIAL.on(controller).pulse().consumeAllPulses()) { MinecraftUtil.setScreen(new RadialMenuScreen( controller, - ControlifyBindings.HOTBAR_LOAD_RADIAL.on(controller), + ControlifyActions.HOTBAR_LOAD_RADIAL, RadialItems.createHotbarLoad().items(), Component.translatable("controlify.radial.hotbar_load_hint"), null )); } - if (ControlifyBindings.HOTBAR_SAVE_RADIAL.on(controller).justPressed()) { + if (ControlifyActions.HOTBAR_SAVE_RADIAL.on(controller).pulse().consumeAllPulses()) { MinecraftUtil.setScreen(new RadialMenuScreen( controller, - ControlifyBindings.HOTBAR_SAVE_RADIAL.on(controller), + ControlifyActions.HOTBAR_SAVE_RADIAL, RadialItems.createHotbarSave().items(), Component.translatable("controlify.radial.hotbar_save_hint"), null @@ -331,12 +324,10 @@ private boolean canSwitchGameMode() { && GameModeCommand.PERMISSION_CHECK.check(minecraft.player.permissions()); } - protected void handlePlayerLookInput(boolean isController) { + public void processPlayerLook(float deltaTime) { LocalPlayer player = this.minecraft.player; - if (!isController || !canProcessLookInput()) { - lookInputX = 0; - lookInputY = 0; + if (!ControllerPlayerMovement.shouldBeControllerInput() || !canProcessLookInput()) { return; } @@ -348,28 +339,30 @@ protected void handlePlayerLookInput(boolean isController) { if (controller.gyro().map(gyro -> gyro.settings().lookSensitivity > 0 && gyro.settings().flickStick).orElse(false)) { handleFlickStick(player); } else { - controller.input().ifPresent(input -> handleRegularLook(input, lookImpulse, aiming, player)); + handleRegularLook(controller.input(), lookImpulse, aiming, player, deltaTime); } var modifier = new LookInputModifier(new Vector2f((float) lookImpulse.x, (float) lookImpulse.y), controller); - ControlifyEvents.LOOK_INPUT_MODIFIER.invoke(modifier); + Events.LOOK_INPUT_MODIFIER.emit(modifier); lookImpulse.set(modifier.lookInput()); - lookInputX = lookImpulse.x; - lookInputY = lookImpulse.y; + double velX = lookImpulse.x / 0.15; + double velY = lookImpulse.y / 0.15; + player.turn(velX, velY); + minecraft.getTutorial().onMouse(velX, velY); wasAiming = aiming; } - protected void handleRegularLook(InputComponent input, Vector2d impulse, boolean aiming, LocalPlayer player) { + protected void handleRegularLook(InputComponentImpl input, Vector2d impulse, boolean aiming, LocalPlayer player, float deltaTime) { InputSettings settings = input.settings(); // normal look input Vector2d regularImpulse = new Vector2d( - ControlifyBindings.LOOK_RIGHT.on(controller).analogueNow() - - ControlifyBindings.LOOK_LEFT.on(controller).analogueNow(), - ControlifyBindings.LOOK_DOWN.on(controller).analogueNow() - - ControlifyBindings.LOOK_UP.on(controller).analogueNow() + ControlifyActions.LOOK_RIGHT.on(controller).continuous().getContinuous() + - ControlifyActions.LOOK_LEFT.on(controller).continuous().getContinuous(), + ControlifyActions.LOOK_DOWN.on(controller).continuous().getContinuous() + - ControlifyActions.LOOK_UP.on(controller).continuous().getContinuous() ); if (settings.sensitivity.hLookInvert) { regularImpulse.x *= -1; @@ -409,56 +402,53 @@ protected void handleRegularLook(InputComponent input, Vector2d impulse, boolean regularImpulse.x *= settings.sensitivity.hLookSensitivity * 10f; regularImpulse.y *= settings.sensitivity.vLookSensitivity * 10f; - impulse.add(regularImpulse); + impulse.add(regularImpulse.mul(deltaTime)); } - protected void handleGyroLook(GyroComponent gyro, Vector2d impulse, boolean aiming) { + protected void handleGyroLook(GyroComponentImpl gyro, Vector2d impulse, boolean aiming) { GyroSettings settings = gyro.settings(); - var gyroButton = ControlifyBindings.GYRO_BUTTON.on(controller); + var gyroButton = ControlifyActions.GYRO_BUTTON.on(controller); + var gyroInput = new Vector3f(gyro.getState()); - if (settings.buttonMode.equals(GyroButtonMode.ON) && (!gyroButton.digitalNow() && !aiming)) { + if (settings.buttonMode.equals(GyroButtonMode.ON) && (!gyroButton.latch().isLatchActive() && !aiming)) { gyroInput.set(0); - } else if(settings.buttonMode.equals(GyroButtonMode.INVERT) && (gyroButton.digitalNow() && !aiming)) { + } else if(settings.buttonMode.equals(GyroButtonMode.INVERT) && (gyroButton.latch().isLatchActive() && !aiming)) { gyroInput.set(0); } else if(settings.buttonMode.equals(GyroButtonMode.TOGGLE) && (!gyroToggledOn && !aiming)) { gyroInput.set(0); - } else { - if (settings.relativeMode) - gyroInput.add(new GyroState(gyro.getState()).mul(0.1f)); - else - gyroInput.set(gyro.getState()); } - if(settings.buttonMode.equals(GyroButtonMode.TOGGLE) && gyroButton.justPressed()) { - gyroToggledOn = !gyroToggledOn; + while (gyroButton.pulse().consumePulse()) { + if (settings.buttonMode.equals(GyroButtonMode.TOGGLE)) { + gyroToggledOn = !gyroToggledOn; + } } - // convert radians per second into degrees per tick - GyroState thisInput = new GyroState(gyroInput) + // The latest gyro sample is already accumulated over this frame, in radians. + var thisInput = gyroInput .mul(Mth.RAD_TO_DEG) - .div(20) .mul(settings.lookSensitivity); - impulse.y += -thisInput.pitch() * (settings.invertPitch ? -1 : 1); + impulse.y += -thisInput.x() * (settings.invertPitch ? -1 : 1); impulse.x += switch (settings.yawMode) { - case YAW -> -thisInput.yaw(); - case ROLL -> -thisInput.roll(); - case BOTH -> -thisInput.yaw() - thisInput.roll(); + case YAW -> -thisInput.y(); + case ROLL -> -thisInput.z(); + case BOTH -> -thisInput.y() - thisInput.z(); } * (settings.invertYaw ? -1 : 1); } protected void handleFlickStick(LocalPlayer player) { - float y = ControlifyBindings.LOOK_DOWN.on(controller).analogueNow() - - ControlifyBindings.LOOK_UP.on(controller).analogueNow(); - float x = ControlifyBindings.LOOK_RIGHT.on(controller).analogueNow() - - ControlifyBindings.LOOK_LEFT.on(controller).analogueNow(); + float y = ControlifyActions.LOOK_DOWN.on(controller).continuous().getContinuous() + - ControlifyActions.LOOK_UP.on(controller).continuous().getContinuous(); + float x = ControlifyActions.LOOK_RIGHT.on(controller).continuous().getContinuous() + - ControlifyActions.LOOK_LEFT.on(controller).continuous().getContinuous(); float flickAngle = Mth.wrapDegrees((float) Mth.atan2(y, x) * Mth.RAD_TO_DEG + 90f); - if (!ControlifyBindings.LOOK_DOWN.on(controller).justPressed() - && !ControlifyBindings.LOOK_UP.on(controller).justPressed() - && !ControlifyBindings.LOOK_LEFT.on(controller).justPressed() - && !ControlifyBindings.LOOK_RIGHT.on(controller).justPressed() + if (!ControlifyActions.LOOK_DOWN.on(controller).pulse().consumeAllPulses() + & !ControlifyActions.LOOK_UP.on(controller).pulse().consumeAllPulses() + & !ControlifyActions.LOOK_LEFT.on(controller).pulse().consumeAllPulses() + & !ControlifyActions.LOOK_RIGHT.on(controller).pulse().consumeAllPulses() ) { return; } @@ -473,16 +463,6 @@ protected void handleFlickStick(LocalPlayer player) { .play(); } - public void processPlayerLook(float deltaTime) { - if (minecraft.player != null) { - double velX = lookInputX / 0.15 * deltaTime; - double velY = lookInputY / 0.15 * deltaTime; - //noinspection SuspiciousNameCombination - minecraft.player.turn(velX, velY); - minecraft.getTutorial().onMouse(velX, velY); - } - } - public boolean shouldShowPlayerList() { return this.shouldShowPlayerList; } @@ -502,10 +482,12 @@ public void preventFlyDrifting() { boolean jumping = player.input.keyPresses.jump(); boolean shiftKeyDown = player.input.keyPresses.shift(); - if (!jumping) + if (!jumping) { y = Math.min(y, 0); - if (!shiftKeyDown) + } + if (!shiftKeyDown) { y = Math.max(y, 0); + } Vec2 moveVec = getMoveVec(player.input); if (moveVec.x == 0 && moveVec.y == 0) { diff --git a/src/main/java/dev/isxander/controlify/ingame/InputCurve.java b/src/main/java/dev/isxander/controlify/impl/ingame/InputCurve.java similarity index 95% rename from src/main/java/dev/isxander/controlify/ingame/InputCurve.java rename to src/main/java/dev/isxander/controlify/impl/ingame/InputCurve.java index 6baa60075..e65140541 100644 --- a/src/main/java/dev/isxander/controlify/ingame/InputCurve.java +++ b/src/main/java/dev/isxander/controlify/impl/ingame/InputCurve.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.ingame; +package dev.isxander.controlify.impl.ingame; /** * A functional interface representing an input curve transformation. diff --git a/src/main/java/dev/isxander/controlify/ingame/InputCurves.java b/src/main/java/dev/isxander/controlify/impl/ingame/InputCurves.java similarity index 96% rename from src/main/java/dev/isxander/controlify/ingame/InputCurves.java rename to src/main/java/dev/isxander/controlify/impl/ingame/InputCurves.java index 6e2229ee5..2e7dc1cdf 100644 --- a/src/main/java/dev/isxander/controlify/ingame/InputCurves.java +++ b/src/main/java/dev/isxander/controlify/impl/ingame/InputCurves.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.ingame; +package dev.isxander.controlify.impl.ingame; import com.mojang.serialization.Codec; import dev.isxander.yacl3.api.NameableEnum; diff --git a/src/main/java/dev/isxander/controlify/ingame/PickBlockAccessor.java b/src/main/java/dev/isxander/controlify/impl/ingame/PickBlockAccessor.java similarity index 83% rename from src/main/java/dev/isxander/controlify/ingame/PickBlockAccessor.java rename to src/main/java/dev/isxander/controlify/impl/ingame/PickBlockAccessor.java index 677bb8dc9..36aa7c2e1 100644 --- a/src/main/java/dev/isxander/controlify/ingame/PickBlockAccessor.java +++ b/src/main/java/dev/isxander/controlify/impl/ingame/PickBlockAccessor.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.ingame; +package dev.isxander.controlify.impl.ingame; public interface PickBlockAccessor { void controlify$pickBlock(); diff --git a/src/main/java/dev/isxander/controlify/impl/input/SensorType.java b/src/main/java/dev/isxander/controlify/impl/input/SensorType.java new file mode 100644 index 000000000..5a3f099f2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/SensorType.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input; + +public enum SensorType { + /// Sensor components are `xyz` and in radians. + GYROSCOPE, + /// Sensor components are `xyz` and in radians. + GYROSCOPE_L, + /// Sensor components are `xyz` and in radians. + GYROSCOPE_R, + /// Sensor components are `xyz` and in m/s^2. + ACCELEROMETER, + /// Sensor components are `xyz` and in m/s^2. + ACCELEROMETER_L, + /// Sensor components are `xyz` and in m/s^2. + ACCELEROMETER_R; + + public boolean isGyroscope() { + return this == GYROSCOPE || this == GYROSCOPE_L || this == GYROSCOPE_R; + } + public boolean isAccelerometer() { + return this == ACCELEROMETER || this == ACCELEROMETER_L || this == ACCELEROMETER_R; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/mapping/ControllerMapping.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/ControllerMapping.java new file mode 100644 index 000000000..a473790d8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/ControllerMapping.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.mapping; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.resources.Identifier; + +import java.util.*; + +public record ControllerMapping( + List mappings, + LinkedHashMap deadzones +) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + MappingEntry.codec() + .listOf() + .validate(entries -> { + if (!validateDistinctMappingSources(entries)) { + return DataResult.error(() -> "Every mapping entry must have a distinct `from` source input"); + } + return DataResult.success(entries); + }) + .fieldOf("mappings") + .forGetter(ControllerMapping::mappings), + Codec.unboundedMap(Identifier.CODEC, DeadzoneGroup.CODEC) + .xmap(LinkedHashMap::new, map -> map) + .validate(groups -> { + if (!validateDisjointDeadzoneGroups(groups.values())) { + return DataResult.error(() -> "An axis must not belong to more than one deadzone group"); + } + return DataResult.success(groups); + }) + .fieldOf("deadzones") + .forGetter(ControllerMapping::deadzones) + ).apply(instance, ControllerMapping::new)); + + public ControllerMapping { + if (!validateDistinctMappingSources(mappings)) { + throw new IllegalArgumentException("Every mapping entry must have a distinct source input"); + } + if (!validateDisjointDeadzoneGroups(deadzones.values())) { + throw new IllegalArgumentException("An axis must not belong to more than one deadzone group"); + } + } + + public static final ControllerMapping NO_MAPPING = new Builder().build(); + public static final ControllerMapping GAMEPAD_DEADZONES_ONLY_MAPPING = new Builder() + .putDeadzoneGroups(GamepadInputs.DEADZONE_GROUPS) + .build(); + + private static boolean validateDistinctMappingSources(Collection entries) { + long entryCount = entries.size(); + long distinctSourceCount = entries.stream() + .map(MappingEntry::sourceInput) + .distinct() + .count(); + return entryCount == distinctSourceCount; + } + + private static boolean validateDisjointDeadzoneGroups(Collection groups) { + long axisCount = groups.stream() + .mapToLong(group -> group.axes().size()) + .sum(); + long distinctAxisCount = groups.stream() + .flatMap(group -> group.axes().stream()) + .distinct() + .count(); + return axisCount == distinctAxisCount; + } + + public static class Builder { + private final List mappings = new ArrayList<>(); + private final LinkedHashMap deadzones = new LinkedHashMap<>(); + + public Builder putMapping(MappingEntry mapping) { + if (mapping == null) + return this; + + this.mappings.add(mapping); + return this; + } + + public Builder putDeadzoneGroups(Iterable deadzoneGroup) { + for (DeadzoneGroup group : deadzoneGroup) { + this.deadzones.put(group.name(), group); + } + return this; + } + + public ControllerMapping build() { + return new ControllerMapping(mappings, deadzones); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/mapping/ControllerMappingManager.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/ControllerMappingManager.java new file mode 100644 index 000000000..1dc9ad5ba --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/ControllerMappingManager.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.mapping; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.JsonOps; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.platform.client.resource.SimpleControlifyReloadListener; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.FileToIdConverter; +import net.minecraft.resources.Identifier; +import net.minecraft.server.packs.resources.Resource; +import net.minecraft.server.packs.resources.ResourceManager; +import net.minecraft.util.Util; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; + +public class ControllerMappingManager implements SimpleControlifyReloadListener { + + private static final FileToIdConverter converter = FileToIdConverter.json("controller/mapping"); + + private final Map mappingById = new HashMap<>(); + + public Optional getMappingById(Identifier id) { + return Optional.ofNullable(this.mappingById.get(id)); + } + + @Override + public CompletableFuture load(ResourceManager manager, Executor executor) { + return CompletableFuture.supplyAsync(() -> { + Map resources = converter.listMatchingResources(manager); + Map mappingById = resources.entrySet().stream() + .map(entry -> Map.entry(converter.fileToId(entry.getKey()), parseFile(entry.getValue()))) + .collect(Util.toMap()); + + return new Preparations(mappingById); + }, executor); + } + + private static ControllerMapping parseFile(Resource resource) { + try (BufferedReader reader = resource.openAsReader()) { + JsonElement json = JsonParser.parseReader(reader); + DataResult mappingResult = ControllerMapping.CODEC.parse(JsonOps.INSTANCE, json); + return mappingResult.getOrThrow(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public CompletableFuture apply(Preparations data, ResourceManager manager, Executor executor) { + return CompletableFuture.runAsync(() -> { + this.mappingById.clear(); + this.mappingById.putAll(data.mappingById); + Controlify.instance().getControllerManager().ifPresent(controllerManager -> { + for (var controller : controllerManager.getAllControllers()) { + controller.input().reloadControllerMapping(); + } + }); + }, executor); + } + + @Override + public Identifier getReloadId() { + return CUtil.rl("controller_mapping"); + } + + public record Preparations(Map mappingById) { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/mapping/DeadzoneGroup.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/DeadzoneGroup.java new file mode 100644 index 000000000..a3524581f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/DeadzoneGroup.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.mapping; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.resources.Identifier; + +import java.util.List; +import java.util.Optional; + +/// A set of axes that share one radial deadzone. +/// Each dimension is a signed physical axis split into two mutually exclusive half-axes. +public record DeadzoneGroup( + Identifier name, + List dimensions +) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("name").forGetter(DeadzoneGroup::name), + Dimension.CODEC.listOf().fieldOf("dimensions").forGetter(DeadzoneGroup::dimensions) + ).apply(instance, DeadzoneGroup::new)); + + public DeadzoneGroup { + dimensions = List.copyOf(dimensions); + List axes = axesOf(dimensions); + if (axes.size() != axes.stream().distinct().count()) { + throw new IllegalArgumentException("Deadzone group " + name + " contains an axis more than once"); + } + } + + /// Every half-axis in this group, in dimension order: positive then negative for each dimension. + public List axes() { + return axesOf(this.dimensions); + } + + private static List axesOf(List dimensions) { + return dimensions.stream() + .mapMulti((dimension, sink) -> { + sink.accept(dimension.positive()); + dimension.negative().ifPresent(sink); + }) + .toList(); + } + + /// @param positive the half-axis for the positive direction + /// @param negative the opposing half-axis, if this dimension is signed + public record Dimension(Identifier positive, Optional negative) { + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("positive").forGetter(Dimension::positive), + Identifier.CODEC.optionalFieldOf("negative").forGetter(Dimension::negative) + ).apply(instance, Dimension::new)); + + public Dimension(Identifier positive, Identifier negative) { + this(positive, Optional.of(negative)); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/controller/input/GamepadInputs.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/GamepadInputs.java similarity index 81% rename from src/main/java/dev/isxander/controlify/controller/input/GamepadInputs.java rename to src/main/java/dev/isxander/controlify/impl/input/mapping/GamepadInputs.java index b663983f6..178c526bb 100644 --- a/src/main/java/dev/isxander/controlify/controller/input/GamepadInputs.java +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/GamepadInputs.java @@ -4,13 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.input; +package dev.isxander.controlify.impl.input.mapping; import com.google.common.collect.Sets; -import dev.isxander.controlify.bindings.input.AxisInput; -import dev.isxander.controlify.bindings.input.ButtonInput; -import dev.isxander.controlify.bindings.input.Input; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; import java.util.List; @@ -61,7 +58,6 @@ private GamepadInputs() { public static final Identifier MISC_5_BUTTON = button("misc_5"); public static final Identifier MISC_6_BUTTON = button("misc_6"); - public static final Identifier RIGHT_PADDLE_1_BUTTON = button("right_paddle_1"); public static final Identifier RIGHT_PADDLE_2_BUTTON = button("right_paddle_2"); public static final Identifier LEFT_PADDLE_1_BUTTON = button("left_paddle_1"); @@ -72,28 +68,15 @@ private GamepadInputs() { public static final Set DEADZONE_GROUPS = Sets.newLinkedHashSet(List.of( new DeadzoneGroup(CUtil.rl("left_stick"), List.of( - LEFT_STICK_AXIS_UP, - LEFT_STICK_AXIS_DOWN, - LEFT_STICK_AXIS_LEFT, - LEFT_STICK_AXIS_RIGHT + new DeadzoneGroup.Dimension(LEFT_STICK_AXIS_UP, LEFT_STICK_AXIS_DOWN), + new DeadzoneGroup.Dimension(LEFT_STICK_AXIS_LEFT, LEFT_STICK_AXIS_RIGHT) )), new DeadzoneGroup(CUtil.rl("right_stick"), List.of( - RIGHT_STICK_AXIS_UP, - RIGHT_STICK_AXIS_DOWN, - RIGHT_STICK_AXIS_LEFT, - RIGHT_STICK_AXIS_RIGHT + new DeadzoneGroup.Dimension(RIGHT_STICK_AXIS_UP, RIGHT_STICK_AXIS_DOWN), + new DeadzoneGroup.Dimension(RIGHT_STICK_AXIS_LEFT, RIGHT_STICK_AXIS_RIGHT) )) )); - public static Input getBind(Identifier id) { - return switch (id.getPath().split("/")[0]) { - case "button" -> new ButtonInput(id); - case "axis" -> new AxisInput(id); - case "hat" -> throw new IllegalArgumentException("Gamepad does not have hat inputs."); - default -> throw new IllegalArgumentException("Unknown bind type: " + id); - }; - } - private static Identifier button(String id) { return CUtil.rl("button/" + id); } diff --git a/src/main/java/dev/isxander/controlify/impl/input/mapping/GestureInputs.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/GestureInputs.java new file mode 100644 index 000000000..4871f7861 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/GestureInputs.java @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.mapping; + +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.resources.Identifier; + +public final class GestureInputs { + + private GestureInputs() { + } + + public static final Identifier SHAKE = CUtil.rl("gesture/shake"); +} diff --git a/src/main/java/dev/isxander/controlify/controller/input/Inputs.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/Inputs.java similarity index 94% rename from src/main/java/dev/isxander/controlify/controller/input/Inputs.java rename to src/main/java/dev/isxander/controlify/impl/input/mapping/Inputs.java index 344672c9b..74ffc4bdb 100644 --- a/src/main/java/dev/isxander/controlify/controller/input/Inputs.java +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/Inputs.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.input; +package dev.isxander.controlify.impl.input.mapping; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; diff --git a/src/main/java/dev/isxander/controlify/controller/input/JoystickInputs.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/JoystickInputs.java similarity index 56% rename from src/main/java/dev/isxander/controlify/controller/input/JoystickInputs.java rename to src/main/java/dev/isxander/controlify/impl/input/mapping/JoystickInputs.java index a39990162..25647dc45 100644 --- a/src/main/java/dev/isxander/controlify/controller/input/JoystickInputs.java +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/JoystickInputs.java @@ -4,15 +4,19 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.input; +package dev.isxander.controlify.impl.input.mapping; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; public final class JoystickInputs { private static final Identifier[] BUTTONS = new Identifier[256]; - private static final Identifier[] AXES = new Identifier[512]; - private static final Identifier[] HATS = new Identifier[256]; + private static final Identifier[] AXES = new Identifier[256 << 1]; + private static final Identifier[] HATS = new Identifier[256 << 2]; + + public enum HatDirection { + UP, RIGHT, DOWN, LEFT + } private JoystickInputs() { } @@ -28,10 +32,7 @@ public static Identifier button(int index) { } public static Identifier axis(int index, boolean positive) { - int cacheIndex = index; - if (!positive) { - cacheIndex += 256; - } + int cacheIndex = (index << 1) | (positive ? 0 : 1); Identifier cache = AXES[cacheIndex]; @@ -42,11 +43,13 @@ public static Identifier axis(int index, boolean positive) { return cache; } - public static Identifier hat(int index) { - Identifier cache = HATS[index]; + public static Identifier hat(int index, HatDirection direction) { + int cacheIndex = (index << 2) | direction.ordinal(); + + Identifier cache = HATS[cacheIndex]; if (cache == null) { - HATS[index] = cache = CUtil.rl("hat/" + index); + HATS[cacheIndex] = cache = CUtil.rl("hat/" + index + "/" + direction.name().toLowerCase()); } return cache; diff --git a/src/main/java/dev/isxander/controlify/controller/input/mapping/MapType.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/MapType.java similarity index 86% rename from src/main/java/dev/isxander/controlify/controller/input/mapping/MapType.java rename to src/main/java/dev/isxander/controlify/impl/input/mapping/MapType.java index 35e83f294..c80b207c5 100644 --- a/src/main/java/dev/isxander/controlify/controller/input/mapping/MapType.java +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/MapType.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.controller.input.mapping; +package dev.isxander.controlify.impl.input.mapping; import com.mojang.serialization.Codec; import net.minecraft.util.StringRepresentable; @@ -12,9 +12,7 @@ public enum MapType implements StringRepresentable { BUTTON, - AXIS, - HAT, - NOTHING; + AXIS; public static final Codec CODEC = StringRepresentable.fromEnum(MapType::values); diff --git a/src/main/java/dev/isxander/controlify/impl/input/mapping/MappingEntry.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/MappingEntry.java new file mode 100644 index 000000000..c764e65bb --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/MappingEntry.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.mapping; + +import com.mojang.serialization.Codec; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.utils.MthExt; +import net.minecraft.resources.Identifier; + +public sealed interface MappingEntry { + /// Transforms an input signal + /// A mapping entry may transform a signal to one with a differing input identifier. + /// This function will only be called when the signal matches {@link sourceInput}. + PhysicalInputSignal apply(PhysicalInputSignal signal); + + /// The physical input identifier this mapping entry transforms. + /// The {@link apply} function will only receive signals whose {@link PhysicalInputSignal#input()} matches. + Identifier sourceInput(); + + MapType inputType(); + + MapType outputType(); + + static Codec codec() { + return MappingEntryCodecs.CODEC; + } + + sealed interface FromButton extends MappingEntry { + record ToButton(Identifier sourceInput, Identifier to, boolean invert, MapType inputType, MapType outputType) implements FromButton { + public ToButton(Identifier sourceInput, Identifier to, boolean invert) { + this(sourceInput, to, invert, MapType.BUTTON, MapType.BUTTON); + } + + @Override + public PhysicalInputSignal apply(PhysicalInputSignal signal) { + if (signal instanceof PhysicalInputSignal.ButtonChanged(long timeNanos, _, boolean state)) { + return new PhysicalInputSignal.ButtonChanged(timeNanos, to, invert != state); + } + return signal; + } + } + + record ToAxis(Identifier sourceInput, Identifier to, float offState, float onState, MapType inputType, MapType outputType) implements FromButton { + public ToAxis(Identifier sourceInput, Identifier to, float offState, float onState) { + this(sourceInput, to, offState, onState, MapType.BUTTON, MapType.AXIS); + } + + @Override + public PhysicalInputSignal apply(PhysicalInputSignal signal) { + if (signal instanceof PhysicalInputSignal.ButtonChanged(long timeNanos, _, boolean state)) { + return new PhysicalInputSignal.AxisMoved(timeNanos, this.to, state ? onState : offState); + } + return signal; + } + } + } + + sealed interface FromAxis extends MappingEntry { + record ToButton(Identifier sourceInput, Identifier to, float threshold, MapType inputType, MapType outputType) implements FromAxis { + public ToButton(Identifier sourceInput, Identifier to, float threshold) { + this(sourceInput, to, threshold, MapType.AXIS, MapType.BUTTON); + } + + @Override + public PhysicalInputSignal apply(PhysicalInputSignal signal) { + if (signal instanceof PhysicalInputSignal.AxisMoved(long timeNanos, _, float value)) { + return new PhysicalInputSignal.ButtonChanged(timeNanos, to, value >= threshold); + } + return signal; + } + } + + record ToAxis(Identifier sourceInput, Identifier to, float minIn, float minOut, float maxIn, float maxOut, MapType inputType, MapType outputType) implements FromAxis { + public ToAxis(Identifier sourceInput, Identifier to, float minIn, float minOut, float maxIn, float maxOut) { + this(sourceInput, to, minIn, minOut, maxIn, maxOut, MapType.AXIS, MapType.AXIS); + } + + @Override + public PhysicalInputSignal apply(PhysicalInputSignal signal) { + if (signal instanceof PhysicalInputSignal.AxisMoved(long timeNanos, _, float value)) { + float clampedInput = Math.clamp(value, Math.min(minIn, maxIn), Math.max(minIn, maxIn)); + float output = minIn == maxIn ? minOut : MthExt.remap(clampedInput, minIn, maxIn, minOut, maxOut); + return new PhysicalInputSignal.AxisMoved(timeNanos, to, Math.clamp(output, 0f, 1f)); + } + return signal; + } + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/mapping/MappingEntryCodecs.java b/src/main/java/dev/isxander/controlify/impl/input/mapping/MappingEntryCodecs.java new file mode 100644 index 000000000..8e94a35b1 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/mapping/MappingEntryCodecs.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.mapping; + +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.Codec; +import com.mojang.serialization.DataResult; +import com.mojang.serialization.DynamicOps; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import net.minecraft.resources.Identifier; + +final class MappingEntryCodecs { + + private static final Codec CODEC_STUB = RecordCodecBuilder.create(instance -> instance.group( + MapType.CODEC.fieldOf("input_type").forGetter(MappingEntryStub::inputType), + MapType.CODEC.fieldOf("output_type").forGetter(MappingEntryStub::outputType) + ).apply(instance, MappingEntryStub::new)); + + public static final Codec CODEC_BUTTON_TO_BUTTON = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromButton.ToButton::sourceInput), + Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromButton.ToButton::to), + Codec.BOOL.fieldOf("invert").forGetter(MappingEntry.FromButton.ToButton::invert), + MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromButton.ToButton::inputType), + MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromButton.ToButton::outputType) + ).apply(instance, MappingEntry.FromButton.ToButton::new)); + + public static final Codec CODEC_BUTTON_TO_AXIS = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromButton.ToAxis::sourceInput), + Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromButton.ToAxis::to), + Codec.FLOAT.fieldOf("off_state").forGetter(MappingEntry.FromButton.ToAxis::offState), + Codec.FLOAT.fieldOf("on_state").forGetter(MappingEntry.FromButton.ToAxis::onState), + MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromButton.ToAxis::inputType), + MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromButton.ToAxis::outputType) + ).apply(instance, MappingEntry.FromButton.ToAxis::new)); + + public static final Codec CODEC_AXIS_TO_BUTTON = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromAxis.ToButton::sourceInput), + Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromAxis.ToButton::to), + Codec.FLOAT.fieldOf("threshold").forGetter(MappingEntry.FromAxis.ToButton::threshold), + MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromAxis.ToButton::inputType), + MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromAxis.ToButton::outputType) + ).apply(instance, MappingEntry.FromAxis.ToButton::new)); + + public static final Codec CODEC_AXIS_TO_AXIS = RecordCodecBuilder.create(instance -> instance.group( + Identifier.CODEC.fieldOf("from").forGetter(MappingEntry.FromAxis.ToAxis::sourceInput), + Identifier.CODEC.fieldOf("to").forGetter(MappingEntry.FromAxis.ToAxis::to), + Codec.FLOAT.fieldOf("min_in").forGetter(MappingEntry.FromAxis.ToAxis::minIn), + Codec.FLOAT.fieldOf("min_out").forGetter(MappingEntry.FromAxis.ToAxis::minOut), + Codec.FLOAT.fieldOf("max_in").forGetter(MappingEntry.FromAxis.ToAxis::maxIn), + Codec.FLOAT.fieldOf("max_out").forGetter(MappingEntry.FromAxis.ToAxis::maxOut), + MapType.CODEC.fieldOf("input_type").forGetter(MappingEntry.FromAxis.ToAxis::inputType), + MapType.CODEC.fieldOf("output_type").forGetter(MappingEntry.FromAxis.ToAxis::outputType) + ).apply(instance, MappingEntry.FromAxis.ToAxis::new)); + + public static final Codec CODEC = new Codec<>() { + @Override + public DataResult encode(MappingEntry input, DynamicOps ops, T prefix) { + return switch (input) { + case MappingEntry.FromButton.ToButton e -> CODEC_BUTTON_TO_BUTTON.encode(e, ops, prefix); + case MappingEntry.FromButton.ToAxis e -> CODEC_BUTTON_TO_AXIS.encode(e, ops, prefix); + case MappingEntry.FromAxis.ToButton e -> CODEC_AXIS_TO_BUTTON.encode(e, ops, prefix); + case MappingEntry.FromAxis.ToAxis e -> CODEC_AXIS_TO_AXIS.encode(e, ops, prefix); + }; + } + + @Override + public DataResult> decode(DynamicOps ops, T input) { + // First decode the stub to determine types + return CODEC_STUB.parse(ops, input).flatMap(stub -> { + Codec codec = switch (stub.inputType()) { + case BUTTON -> switch (stub.outputType()) { + case BUTTON -> CODEC_BUTTON_TO_BUTTON; + case AXIS -> CODEC_BUTTON_TO_AXIS; + }; + case AXIS -> switch (stub.outputType()) { + case BUTTON -> CODEC_AXIS_TO_BUTTON; + case AXIS -> CODEC_AXIS_TO_AXIS; + }; + }; + return codec.decode(ops, input) + .map(pair -> pair.mapFirst(entry -> entry)); + }); + } + }; + + + private record MappingEntryStub(MapType inputType, MapType outputType) { + } + + private MappingEntryCodecs() { + } + +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/DeadzoneStage.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/DeadzoneStage.java new file mode 100644 index 000000000..5dc7f6611 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/DeadzoneStage.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +import dev.isxander.controlify.impl.input.mapping.DeadzoneGroup; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.UnaryEventStage; +import it.unimi.dsi.fastutil.objects.*; +import net.minecraft.resources.Identifier; +import net.minecraft.util.Mth; +import org.jspecify.annotations.NonNull; + +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/// Applies radial deadzones to physical axes. +public class DeadzoneStage implements UnaryEventStage { + + private Map stateByAxis; + private Object2FloatFunction deadzoneByGroupName; + + public DeadzoneStage(Set deadzoneGroups) { + this.setDeadzoneGroups(deadzoneGroups); + this.deadzoneByGroupName = _ -> 0f; + } + + public DeadzoneStage() { + this(Set.of()); + } + + @Override + public void onEvent(@NonNull PhysicalSignal event, @NonNull EventSink downstream) { + if (event instanceof PhysicalInputSignal.AxisMoved axisMoved) { + RadialDeadzoneState state = this.stateByAxis.get(axisMoved.input()); + + if (state != null) { + state.onEvent(axisMoved, downstream); + return; + } + } + + downstream.accept(event); + } + + public void setDeadzoneGroups(Collection deadzoneGroups) { + this.stateByAxis = deadzoneGroups.stream() + .map(RadialDeadzoneState::new) + .flatMap(state -> state.deadzoneGroup.axes().stream().map(axis -> Map.entry(axis, state))) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (_, _) -> { + throw new IllegalArgumentException("Axis belongs to multiple deadzone groups"); + }, + Object2ObjectOpenHashMap::new + )); + } + + public void setDeadzoneSupplier(Object2FloatFunction deadzoneByGroupName) { + this.deadzoneByGroupName = deadzoneByGroupName; + } + + private class RadialDeadzoneState implements UnaryEventStage { + private final DeadzoneGroup deadzoneGroup; + private final Object2FloatMap rawAxes = new Object2FloatArrayMap<>(4); + private final Object2FloatMap outputAxes = new Object2FloatArrayMap<>(4); + + public RadialDeadzoneState(DeadzoneGroup deadzoneGroup) { + this.deadzoneGroup = deadzoneGroup; + + for (Identifier axis : deadzoneGroup.axes()) { + this.rawAxes.put(axis, 0.0f); + this.outputAxes.put(axis, 0.0f); + } + } + + @Override + public void onEvent(PhysicalInputSignal.@NonNull AxisMoved event, @NonNull EventSink downstream) { + this.rawAxes.put(event.input(), event.value()); + + float deadzone = DeadzoneStage.this.deadzoneByGroupName.getFloat(this.deadzoneGroup.name()); + + float magnitudeSqr = 0.0f; + for (DeadzoneGroup.Dimension dimension : this.deadzoneGroup.dimensions()) { + // Opposing half-axes are exclusive, but both may be briefly non-zero while the source + // crosses its centre. Using the larger keeps the magnitude stable across that transition. + float value = this.rawAxes.getFloat(dimension.positive()); + if (dimension.negative().isPresent()) { + value = Math.max(value, this.rawAxes.getFloat(dimension.negative().get())); + } + magnitudeSqr += value * value; + } + float magnitude = Mth.sqrt(magnitudeSqr); + + float scale; + if (deadzone >= 1.0f || magnitude <= deadzone || magnitude == 0.0f) { + scale = 0.0f; + } else { + float outputMagnitude = Math.min( + (magnitude - deadzone) / (1.0f - deadzone), + 1.0f + ); + scale = outputMagnitude / magnitude; + } + + for (var entry : Object2FloatMaps.fastIterable(this.rawAxes)) { + Identifier axis = entry.getKey(); + + float newValue = entry.getFloatValue() * scale; + float oldValue = this.outputAxes.put(axis, newValue); + + if (newValue != oldValue) { + downstream.accept(new PhysicalInputSignal.AxisMoved(event.timeNanos(), axis, newValue)); + } + } + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/GestureRecogniserStage.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/GestureRecogniserStage.java new file mode 100644 index 000000000..2f1d66fba --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/GestureRecogniserStage.java @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +import dev.isxander.controlify.impl.input.mapping.GestureInputs; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.UnaryEventStage; +import org.jspecify.annotations.NonNull; + +/// Processes sensor data and recognizes distinct gestures such as shaking of the controller. +/// This stage then emits {@link PhysicalInputSignal.ButtonChanged} events +/// for when the gesture begins and ends using input identifiers from {@link GestureInputs}. +public class GestureRecogniserStage implements UnaryEventStage { + @Override + public void onEvent(@NonNull PhysicalSignal event, @NonNull EventSink downstream) { + // TODO + downstream.accept(event); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/GestureTimings.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/GestureTimings.java new file mode 100644 index 000000000..af16c74fd --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/GestureTimings.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +/// Button gesture timings used by {@link PhysicalToLogicalStage}. +/// @param tapMaxNanos the longest press that still counts as a tap +/// @param doubleTapGapNanos the longest gap between releasing a tap and pressing again for a double tap +/// @param holdNanos how long a press lasts before it counts as held; always greater than tapMaxNanos +public record GestureTimings(long tapMaxNanos, long doubleTapGapNanos, long holdNanos) { + public static final GestureTimings DEFAULT = ofMillis(200, 200, 400); + + public GestureTimings { + if (tapMaxNanos < 0 || doubleTapGapNanos < 0 || holdNanos <= tapMaxNanos) { + throw new IllegalArgumentException("Gesture durations must be nonnegative and hold must exceed tap"); + } + } + + /// Raises the hold duration above the tap duration if needed, so unvalidated settings never throw. + public static GestureTimings ofMillis(int tapMaxMs, int doubleTapGapMs, int holdMs) { + int tapMax = Math.max(0, tapMaxMs); + return new GestureTimings( + tapMax * 1_000_000L, + Math.max(0, doubleTapGapMs) * 1_000_000L, + Math.max(holdMs, tapMax + 1) * 1_000_000L + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/InputDomainExpansionStage.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/InputDomainExpansionStage.java new file mode 100644 index 000000000..ecd337c1c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/InputDomainExpansionStage.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.UnaryEventStage; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.DoubleSupplier; + +/// Expands the domain of physical inputs. +/// - Expands button presses and releases into axis moved events. +/// - Expands axis moved events into button presses and releases when passing threshold. +public class InputDomainExpansionStage implements UnaryEventStage { + + private final Set pressedButtons; + private DoubleSupplier actuation, release; + private final float pressedValue, releasedValue; + + public InputDomainExpansionStage(float actuation, float release, float pressedValue, float releasedValue) { + this.pressedButtons = new HashSet<>(); + this.actuation = () -> actuation; + this.release = () -> release; + this.pressedValue = pressedValue; + this.releasedValue = releasedValue; + } + + public void setThresholdSupplier(DoubleSupplier threshold) { + this.actuation = threshold; + this.release = () -> Math.max(0, threshold.getAsDouble() - 0.01); + } + + public void resetInputState() { + this.pressedButtons.clear(); + } + + @Override + public void onEvent(@NonNull PhysicalSignal event, @NonNull EventSink downstream) { + downstream.accept(event); + + switch (event) { + case PhysicalInputSignal.AxisMoved(long timeNanos, Identifier input, float value) -> { + if (value >= this.actuation.getAsDouble()) { + if (this.pressedButtons.add(input)) { + downstream.accept(new PhysicalInputSignal.ButtonChanged(timeNanos, input, true)); + } + } else if (value <= this.release.getAsDouble()) { + if (this.pressedButtons.remove(input)) { + downstream.accept(new PhysicalInputSignal.ButtonChanged(timeNanos, input, false)); + } + } + } + case PhysicalInputSignal.ButtonChanged(long timeNanos, Identifier input, boolean state) -> { + downstream.accept(new PhysicalInputSignal.AxisMoved(timeNanos, input, state ? pressedValue : releasedValue)); + } + default -> {} + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/InputPipeline.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/InputPipeline.java new file mode 100644 index 000000000..1cba6c27d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/InputPipeline.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +import dev.isxander.controlify.impl.input.mapping.ControllerMapping; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.impl.utils.event.EventTap; +import org.jspecify.annotations.NonNull; + +public class InputPipeline implements EventSink { + private final EventTap tapRawPhysicalSignals; + private final SensorProcessorStage stageSensorProcessor; + private final MappingStage stageMapping; + private final DeadzoneStage stageDeadzone; + private final GestureRecogniserStage stageGestureRecogniser; + private final InputDomainExpansionStage stageInputDomainExpansion; + private final EventTap tapProcessedPhysicalSignals; + + private final SensorAccumulatorStage stageSensorAccumulator; + private final PhysicalToLogicalStage stagePhysicalToLogical; + private final EventTap tapLogicalSignals; + + private final EventSink fullPipeline; + + public InputPipeline() { + this.tapRawPhysicalSignals = new EventTap<>(); + this.stageSensorProcessor = new SensorProcessorStage(); + this.stageMapping = new MappingStage(ControllerMapping.NO_MAPPING); + this.stageDeadzone = new DeadzoneStage(); + this.stageGestureRecogniser = new GestureRecogniserStage(); + this.stageInputDomainExpansion = new InputDomainExpansionStage(0.5f, 0.49f, 1f, 0f); + this.tapProcessedPhysicalSignals = new EventTap<>(); + this.stageSensorAccumulator = new SensorAccumulatorStage(); + this.stagePhysicalToLogical = new PhysicalToLogicalStage(this.stageSensorAccumulator); + this.tapLogicalSignals = new EventTap<>(); + + this.fullPipeline = this.tapRawPhysicalSignals + .andThen(this.stageSensorProcessor) + .andThen(this.stageMapping) + .andThen(this.stageDeadzone) + .andThen(this.stageGestureRecogniser) + .andThen(this.stageInputDomainExpansion) + .andThen(this.tapProcessedPhysicalSignals) + .andThen(this.stagePhysicalToLogical) + .andThen(this.tapLogicalSignals) + .discardDownstream(); + } + + /// Applies a new physical mapping without reopening the driver or replacing event sources. + /// The caller must reset actions to release outputs based on the previous mapping. + public void setControllerMapping(ControllerMapping mapping) { + this.stageMapping.setControllerMapping(mapping); + this.stageDeadzone.setDeadzoneGroups(mapping.deadzones().values()); + this.stageInputDomainExpansion.resetInputState(); + this.stagePhysicalToLogical.resetInputState(); + } + + @Override + public void accept(@NonNull PhysicalSignal event) { + this.fullPipeline.accept(event); + } + + public EventSource sourceRawPhysicalSignals() { + return this.tapRawPhysicalSignals; + } + + public SensorProcessorStage stageSensorProcessor() { + return this.stageSensorProcessor; + } + + public MappingStage stageMapping() { + return this.stageMapping; + } + + public DeadzoneStage stageDeadzone() { + return this.stageDeadzone; + } + + public GestureRecogniserStage stageGestureRecogniser() { + return this.stageGestureRecogniser; + } + + public InputDomainExpansionStage stageInputDomainExpansion() { + return this.stageInputDomainExpansion; + } + + public EventSource sourceProcessedPhysicalSignals() { + return this.tapProcessedPhysicalSignals; + } + + public SensorAccumulatorStage stageSensorAccumulator() { + return this.stageSensorAccumulator; + } + + public PhysicalToLogicalStage stagePhysicalToLogical() { + return this.stagePhysicalToLogical; + } + + public EventSource sourceLogicalSignals() { + return this.tapLogicalSignals; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/MappingStage.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/MappingStage.java new file mode 100644 index 000000000..4b4735f2f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/MappingStage.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +import dev.isxander.controlify.impl.input.mapping.ControllerMapping; +import dev.isxander.controlify.impl.input.mapping.MappingEntry; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.UnaryEventStage; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class MappingStage implements UnaryEventStage { + private Map mappingByInput; + + public MappingStage(ControllerMapping mapping) { + this.setControllerMapping(mapping); + } + + @Override + public void onEvent(@NonNull PhysicalSignal event, @NonNull EventSink downstream) { + if (event instanceof PhysicalInputSignal inputSignal) { + MappingEntry mapping = this.mappingByInput.get(inputSignal.input()); + + if (mapping != null) { + downstream.accept(mapping.apply(inputSignal)); + return; + } + } + downstream.accept(event); + } + + public void setControllerMapping(ControllerMapping mapping) { + this.mappingByInput = mapping.mappings().stream() + .collect(Collectors.toUnmodifiableMap( + MappingEntry::sourceInput, + Function.identity() + )); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/PhysicalToLogicalStage.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/PhysicalToLogicalStage.java new file mode 100644 index 000000000..12abcab15 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/PhysicalToLogicalStage.java @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalInputSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSensorSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.EventStage; +import net.minecraft.resources.Identifier; +import org.jspecify.annotations.NonNull; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/// Converts physical inputs into single inputs and modifier chords. +/// A press emits every chord it could form with the buttons already held, up to +/// {@link LogicalInput.ModifiedChord#MAX_MODIFIERS} modifiers, followed by the single input. +/// Chords are ordered most specific first: more modifiers, then more recently pressed modifiers. +/// This stage does not know what is bound; consumers choose between the candidates. +/// Each chord keeps its modifiers until any of its buttons is released, +/// and follows the same gesture lifecycle as a single button. +/// +/// Button gestures use event timestamps, with holds emitted once on a tick. +/// Tap and double tap are mutually exclusive for an actuation. After a tap is released, the stage +/// waits up to {@link GestureTimings#doubleTapGapNanos()} for the next press. If none comes, the tap is +/// emitted on the next frame. If one does, the tap is emitted as soon as that press can no longer be a +/// tap, and a {@link LogicalSignal.DoubleTapped} replaces both if it is. +/// Call {@link #onGuiNavigate()} when GUI navigation consumes a pending press. +/// Sensor integration is delegated to {@link SensorAccumulatorStage}. +public class PhysicalToLogicalStage implements EventStage { + private final SensorAccumulatorStage sensorAccumulatorStage; + private Supplier timingsSupplier; + /// Read once per event so a settings change never splits one event's decisions. + private GestureTimings timings; + + // Press order lets an older held button become the latest modifier again when a newer one is released. + private final LinkedHashSet pressedButtons = new LinkedHashSet<>(); + private final Map activeButtons = new LinkedHashMap<>(); + /// Logical input -> release time of a tap waiting to see whether it becomes a double tap. + private final Map pendingTaps = new LinkedHashMap<>(); + private final Map axisValues = new HashMap<>(); + + public PhysicalToLogicalStage(SensorAccumulatorStage sensorAccumulatorStage) { + this(sensorAccumulatorStage, GestureTimings.DEFAULT); + } + + public PhysicalToLogicalStage(SensorAccumulatorStage sensorAccumulatorStage, GestureTimings timings) { + this.sensorAccumulatorStage = Objects.requireNonNull(sensorAccumulatorStage); + this.setTimingsSupplier(() -> timings); + } + + /// Timings are read on every event, so changes apply immediately. + public void setTimingsSupplier(Supplier timingsSupplier) { + this.timingsSupplier = Objects.requireNonNull(timingsSupplier); + this.timings = timingsSupplier.get(); + } + + /// Clears pending gestures and axis values after the physical mapping changes. + public void resetInputState() { + this.pressedButtons.clear(); + this.activeButtons.clear(); + this.pendingTaps.clear(); + this.axisValues.clear(); + } + + /// Cancels GUI presses for buttons already held; subsequent presses remain eligible. + public void onGuiNavigate() { + this.activeButtons.values().forEach(state -> state.guiPressEligible = false); + } + + @Override + public void onEvent(@NonNull PhysicalSignal event, @NonNull EventSink downstream) { + this.timings = this.timingsSupplier.get(); + switch (event) { + case PhysicalSignal.Frame(long timeNanos, long deltaTimeNanos) -> { + this.sensorAccumulatorStage.onEvent(event, downstream); + // Resolve taps before holds: a second press is never held before it stops being a possible tap. + var pending = this.pendingTaps.entrySet().iterator(); + while (pending.hasNext()) { + var entry = pending.next(); + ButtonState secondPress = this.activeButtons.get(entry.getKey()); + boolean resolved = secondPress != null && secondPress.secondTap + ? timeNanos - secondPress.pressedAt > this.timings.tapMaxNanos() + : timeNanos - entry.getValue() > this.timings.doubleTapGapNanos(); + if (resolved) { + pending.remove(); + downstream.accept(new LogicalSignal.Tapped(timeNanos, entry.getKey())); + } + } + // Holds must fire even without new input events, before downstream actions process this frame's tick. + this.activeButtons.forEach((input, state) -> { + if (!state.held && timeNanos - state.pressedAt >= this.timings.holdNanos()) { + state.held = true; + downstream.accept(new LogicalSignal.Held(timeNanos, input)); + } + }); + downstream.accept(new LogicalSignal.Frame(timeNanos, deltaTimeNanos)); + } + case PhysicalSensorSignal sensorSignal -> + this.sensorAccumulatorStage.onEvent(sensorSignal, downstream); + case PhysicalInputSignal.ButtonChanged(long timeNanos, Identifier input, boolean state) -> + this.onButton(timeNanos, input, state, downstream); + case PhysicalInputSignal.AxisMoved(long timeNanos, Identifier input, float value) -> + this.onAxis(timeNanos, input, value, downstream); + } + } + + private void onButton(long time, Identifier input, boolean pressed, EventSink downstream) { + if (this.pressedButtons.contains(input) == pressed) { + // Duplicate edges must not restart gesture timers or change modifier priority. + return; + } + var single = new LogicalInput.Single(input); + if (pressed) { + // Only buttons held before this press can modify it; adding the trigger first would select itself. + for (LogicalInput.ModifiedChord chord : this.recogniseChords(input)) { + this.press(time, chord, downstream); + } + this.press(time, single, downstream); + this.pressedButtons.add(input); + } else { + this.pressedButtons.remove(input); + // Use the chords captured on press, so later presses cannot change which chord gets released. + // Snapshot because releasing a chord removes it from activeButtons. + // Release most specific first, matching press order, so consumers capturing the first release get the largest chord. + List released = this.activeButtons.keySet().stream() + .filter(LogicalInput.ModifiedChord.class::isInstance) + .map(LogicalInput.ModifiedChord.class::cast) + .filter(chord -> chord.trigger().equals(input) || chord.modifiers().contains(input)) + .sorted(Comparator.comparingInt((LogicalInput.ModifiedChord chord) -> chord.modifiers().size()).reversed()) + .toList(); + for (var chord : released) { + this.release(time, chord, downstream); + } + this.release(time, single, downstream); + } + } + + private void press(long time, LogicalInput input, EventSink downstream) { + var state = new ButtonState(time); + Long pendingTap = this.pendingTaps.get(input); + if (pendingTap != null) { + if (time >= pendingTap && time - pendingTap <= this.timings.doubleTapGapNanos()) { + state.secondTap = true; + } else { + // The gap expired between frames; the earlier tap was a lone one. + this.pendingTaps.remove(input); + downstream.accept(new LogicalSignal.Tapped(time, input)); + } + } + this.activeButtons.put(input, state); + downstream.accept(new LogicalSignal.ButtonPress(time, input)); + } + + private void release(long time, LogicalInput input, EventSink downstream) { + ButtonState state = this.activeButtons.remove(input); + if (state == null) { + return; + } + long duration = time - state.pressedAt; + boolean isTap = !state.held && duration >= 0 && duration <= this.timings.tapMaxNanos(); + Long previousTap = this.pendingTaps.remove(input); + boolean isDoubleTap = isTap && state.secondTap && previousTap != null; + if (previousTap != null && !isDoubleTap) { + // The previous actuation was a lone tap. Emit it before this release so it stays attributed to that actuation. + downstream.accept(new LogicalSignal.Tapped(time, input)); + } + + downstream.accept(new LogicalSignal.ButtonRelease(time, input)); + if (isDoubleTap) { + // Both taps are consumed, so three quick taps form one double tap and a pending tap. + downstream.accept(new LogicalSignal.DoubleTapped(time, input)); + } else if (isTap) { + this.pendingTaps.put(input, time); + } + if (state.guiPressEligible && duration >= 0) { + downstream.accept(new LogicalSignal.GuiPress(time, input)); + } + } + + private void onAxis(long time, Identifier input, float value, EventSink downstream) { + // Domain expansion supplies button edges for axis gestures; raw values only need continuous deltas here. + float previous = this.axisValues.getOrDefault(input, 0.0f); + if (value == previous) { + return; + } + if (value == 0.0f) { + this.axisValues.remove(input); + } else { + this.axisValues.put(input, value); + } + downstream.accept(new LogicalSignal.AxisMoved(time, new LogicalInput.Single(input), value, value - previous)); + } + + /// @return every chord the trigger forms with held buttons, most specific first + private List recogniseChords(Identifier trigger) { + if (this.pressedButtons.isEmpty()) { + return List.of(); + } + List heldByRecency = new ArrayList<>(this.pressedButtons).reversed(); + List chords = new ArrayList<>(); + int maxModifiers = Math.min(LogicalInput.ModifiedChord.MAX_MODIFIERS, heldByRecency.size()); + for (int size = maxModifiers; size >= 1; size--) { + collectCombinations(heldByRecency, size, 0, new ArrayList<>(), modifiers -> + chords.add(new LogicalInput.ModifiedChord(Set.copyOf(modifiers), trigger))); + } + return chords; + } + + /// Emits combinations in lexicographic order of recency, so more recent modifiers come first. + private static void collectCombinations( + List items, int size, int from, + List current, Consumer> output + ) { + if (current.size() == size) { + output.accept(current); + return; + } + for (int i = from; i <= items.size() - (size - current.size()); i++) { + current.add(items.get(i)); + collectCombinations(items, size, i + 1, current, output); + current.removeLast(); + } + } + + private static final class ButtonState { + private final long pressedAt; + private boolean held; + private boolean guiPressEligible = true; + /// Pressed within the double tap gap of an earlier tap on the same input. + private boolean secondTap; + + private ButtonState(long pressedAt) { + this.pressedAt = pressedAt; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/SensorAccumulatorStage.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/SensorAccumulatorStage.java new file mode 100644 index 000000000..6d86bcc44 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/SensorAccumulatorStage.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +import dev.isxander.controlify.impl.input.SensorType; +import dev.isxander.controlify.impl.input.signal.LogicalSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSensorSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.EventStage; +import org.jspecify.annotations.NonNull; + +import java.util.EnumMap; +import java.util.Map; + +public class SensorAccumulatorStage implements EventStage { + // prevent stale readings from being continually accumulated + private static final long MAX_SAMPLE_AGE_NANOS = 50_000_000L; + + private final Map accSensorValues = new EnumMap<>(SensorType.class); + + @Override + public void onEvent( + @NonNull PhysicalSignal event, + @NonNull EventSink downstream + ) { + switch (event) { + case PhysicalSignal.Frame(long timeNanos, _) -> { + this.accSensorValues.values().removeIf(acc -> + acc.lastSampleNanos != -1 && timeNanos - acc.lastSampleNanos > MAX_SAMPLE_AGE_NANOS); + this.accSensorValues.forEach((sensorType, acc) -> { + if (acc.lastNs == -1) { + return; + } + + acc.integrateUntil(timeNanos); + + if (acc.accumulatedNanos > 0) { + float x = acc.x; + float y = acc.y; + float z = acc.z; + + if (sensorType.isAccelerometer()) { + float durationSeconds = acc.accumulatedNanos * 1e-9f; + + x /= durationSeconds; + y /= durationSeconds; + z /= durationSeconds; + } + + downstream.accept(new LogicalSignal.SensorInterval( + timeNanos, + acc.accumulatedNanos, + sensorType, + x, + y, + z + )); + } + + acc.resetAccumulation(); + }); + } + + case PhysicalSensorSignal(long timeNanos, _, SensorType sensorType, float x, float y, float z) -> { + Acc acc = this.accSensorValues.computeIfAbsent(sensorType, _ -> new Acc()); + + + acc.integrateUntil(timeNanos); + + acc.velocityX = x; + acc.velocityY = y; + acc.velocityZ = z; + acc.lastSampleNanos = Math.max(acc.lastSampleNanos, timeNanos); + } + + default -> { + } + } + } + + private static final class Acc { + private long lastNs = -1; + private long lastSampleNanos = -1; + + private float velocityX; + private float velocityY; + private float velocityZ; + + private float x; + private float y; + private float z; + + private long accumulatedNanos; + + public void integrateUntil(long timeNanos) { + if (lastNs == -1) { + lastNs = timeNanos; + return; + } + + long deltaNanos = timeNanos - lastNs; + + if (deltaNanos <= 0) { + return; + } + + if (lastSampleNanos == -1 || timeNanos - lastSampleNanos > MAX_SAMPLE_AGE_NANOS) { + // A gap invalidates both the held sample and any motion waiting for a frame. + resetAccumulation(); + lastSampleNanos = -1; + lastNs = timeNanos; + return; + } + + float deltaSeconds = deltaNanos * 1e-9f; + x += velocityX * deltaSeconds; + y += velocityY * deltaSeconds; + z += velocityZ * deltaSeconds; + accumulatedNanos += deltaNanos; + lastNs = timeNanos; + } + + public void resetAccumulation() { + x = 0.0f; + y = 0.0f; + z = 0.0f; + accumulatedNanos = 0; + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/pipeline/SensorProcessorStage.java b/src/main/java/dev/isxander/controlify/impl/input/pipeline/SensorProcessorStage.java new file mode 100644 index 000000000..a05c85fa8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/pipeline/SensorProcessorStage.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.pipeline; + +import dev.isxander.controlify.impl.input.SensorType; +import dev.isxander.controlify.impl.input.signal.PhysicalSensorSignal; +import dev.isxander.controlify.impl.input.signal.PhysicalSignal; +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.UnaryEventStage; +import org.joml.Vector3f; +import org.joml.Vector3fc; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.function.DoubleSupplier; +import java.util.function.Function; +import java.util.function.Supplier; + +public class SensorProcessorStage implements UnaryEventStage { + private final Map> processors; + + public SensorProcessorStage() { + this(_ -> List.of()); + } + + public SensorProcessorStage(Function> processorFactory) { + this.processors = new EnumMap<>(SensorType.class); + this.setProcessors(processorFactory); + } + + public void setProcessors(Function> processorFactory) { + this.processors.clear(); + for (SensorType sensorType : SensorType.values()) { + this.processors.put(sensorType, List.copyOf(processorFactory.apply(sensorType))); + } + } + + /// Configures one sensor without replacing processing owned by other components. + public void setProcessors(SensorType sensorType, List processors) { + this.processors.put(sensorType, List.copyOf(processors)); + } + + @Override + public void onEvent(@NonNull PhysicalSignal event, @NonNull EventSink downstream) { + switch (event) { + case PhysicalSensorSignal(long timeNanos, long sensorTimeNanos, SensorType sensorType, float x, float y, float z) + when !this.processors.get(sensorType).isEmpty() -> { + var data = new Vector3f(x, y, z); + + for (SensorProcessor processor : this.processors.get(sensorType)) { + // Filtering and integration share the host event clock, including frame boundaries. + processor.process(data, timeNanos); + } + + downstream.accept(new PhysicalSensorSignal(timeNanos, sensorTimeNanos, sensorType, data.x, data.y, data.z)); + } + default -> downstream.accept(event); + } + } + + public interface SensorProcessor { + void process(Vector3f data, long timeNanos); + } + + public record BiasCorrection(Supplier bias) implements SensorProcessor { + @Override + public void process(Vector3f data, long timeNanos) { + data.sub(this.bias.get()); + } + } + + /// A first-order low-pass filter with a finite, positive cutoff frequency in Hz. + /// Lower frequencies smooth more strongly. The first sample passes through unchanged. + /// History expires after a gap of 50ms or when the host clock does not advance. + /// + /// If the frequency is set to 0, the filter is bypassed. + public static class LowPassFilter implements SensorProcessor { + private static final long MAX_SAMPLE_AGE_NANOS = 50_000_000L; + private final DoubleSupplier cutoffHz; + private @Nullable Vector3f previous; + private long previousTimeNanos; + + public LowPassFilter(DoubleSupplier cutoffHz) { + this.cutoffHz = cutoffHz; + } + + @Override + public void process(Vector3f data, long timeNanos) { + double cutoffHz = this.cutoffHz.getAsDouble(); + + long elapsedNanos = timeNanos - this.previousTimeNanos; + if (cutoffHz == 0 || this.previous == null + || elapsedNanos > MAX_SAMPLE_AGE_NANOS || elapsedNanos <= 0) { + this.previous = new Vector3f(data); + this.previousTimeNanos = timeNanos; + } else { + // Exponential decay keeps the response consistent across sample rates. + // expm1 preserves precision for very short sample intervals. + double decayPerNano = 2.0 * Math.PI * cutoffHz * 1e-9; + float blend = (float) -Math.expm1(-elapsedNanos * decayPerNano); + this.previous.lerp(data, blend); + this.previousTimeNanos = timeNanos; + data.set(this.previous); + } + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/signal/LogicalSignal.java b/src/main/java/dev/isxander/controlify/impl/input/signal/LogicalSignal.java new file mode 100644 index 000000000..afe7b0c0b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/signal/LogicalSignal.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.signal; + +import dev.isxander.controlify.api.input.LogicalInput; +import dev.isxander.controlify.impl.input.SensorType; +import dev.isxander.controlify.impl.input.pipeline.SensorAccumulatorStage; + +public sealed interface LogicalSignal { + /// The time this signal occurred. + /// Every signal will have the same epoch. + long timeNanos(); + + /// Submitted once per frame. + /// Allows bindings to accumulate state between ticks. + record Frame(long timeNanos, long deltaTimeNanos) implements LogicalSignal {} + + sealed interface InputSignal extends LogicalSignal { + LogicalInput input(); + } + /// When the button is pressed down + record ButtonPress(long timeNanos, LogicalInput input) implements InputSignal {} + + /// When the button is released + record ButtonRelease(long timeNanos, LogicalInput input) implements InputSignal {} + + /// When button is pressed and released in quick succession, and no second tap followed + /// within the double tap window. Emitted after that window, so never for an actuation that + /// became a {@link DoubleTapped}, and after a later press of the same input may have begun. + record Tapped(long timeNanos, LogicalInput input) implements InputSignal {} + + /// When two taps occur in quick succession. Neither tap produces {@link Tapped}. + record DoubleTapped(long timeNanos, LogicalInput input) implements InputSignal {} + + /// When a button is pressed and released, where no GUI navigation happened in between + record GuiPress(long timeNanos, LogicalInput input) implements InputSignal {} + + /// When a button is held for a long period of time. Signaled whilst still held. + record Held(long timeNanos, LogicalInput input) implements InputSignal {} + + /// When an in-progress press or axis movement stops reaching an action, for example + /// because a higher context started consuming the input or the action's contexts became inactive. + /// Ends anything the input was holding without firing; no release follows for that press. + /// Produced by the action graph, never by the input pipeline. + record Cancelled(long timeNanos, LogicalInput input) implements InputSignal {} + + /// When an axis value changes + record AxisMoved(long timeNanos, LogicalInput input, float value, float delta) implements InputSignal {} + + /// Represents sensor data, which can be multi-dimensional. + /// The data is expected to be tick-integrated deltas. They are not individual IMU samples. + /// @see SensorAccumulatorStage + record SensorInterval(long timeNanos, long deltaTimeNanos, SensorType sensorType, float x, float y, float z) implements LogicalSignal {} +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalInputSignal.java b/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalInputSignal.java new file mode 100644 index 000000000..f46414932 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalInputSignal.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.signal; + +import net.minecraft.resources.Identifier; + +public sealed interface PhysicalInputSignal extends PhysicalSignal { + /// @return the identifier of the physical input + Identifier input(); + + /// Represents a button press/release event. + /// @param timeNanos the timestamp of the event in nanoseconds + /// @param input the identifier of the physical button + /// @param state the state of the button, where true indicates pressed and false indicates released + record ButtonChanged(long timeNanos, Identifier input, boolean state) implements PhysicalInputSignal {} + + /// Represents an axis movement event. + /// @param timeNanos the timestamp of the event in nanoseconds + /// @param input the identifier of the physical axis + /// @param value the position of the axis, *always* in the range `[0.0, 1.0]` + record AxisMoved(long timeNanos, Identifier input, float value) implements PhysicalInputSignal {} +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalSensorSignal.java b/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalSensorSignal.java new file mode 100644 index 000000000..07c384b14 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalSensorSignal.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.signal; + +import dev.isxander.controlify.impl.input.SensorType; + +/// Represents a single IMU sample +/// @param timeNanos timestamp of the event in nanoseconds +/// @param sensorTimeNanos timestamp provided by the sensor firmware in nanoseconds. +/// this may be out of sync with the system clock and therefore {@link timeNanos} +/// @param sensorType the type of sensor this signal is for +/// @param x the first component of the sensor data +/// @param y the second component of the sensor data; +/// may be zero if the sensor data is not at least 2-dimensional +/// @param z the third component of the sensor data; +/// may be zero if the sensor data is not at least 3-dimensional +public record PhysicalSensorSignal( + long timeNanos, + long sensorTimeNanos, + SensorType sensorType, + float x, float y, float z +) implements PhysicalSignal { +} diff --git a/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalSignal.java b/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalSignal.java new file mode 100644 index 000000000..59f730942 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/input/signal/PhysicalSignal.java @@ -0,0 +1,15 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.input.signal; + +public sealed interface PhysicalSignal permits PhysicalInputSignal, PhysicalSensorSignal, PhysicalSignal.Frame { + /// timestamp of the signal in nanoseconds + long timeNanos(); + + /// Emitted at the end of each polling / every frame. + record Frame(long timeNanos, long deltaTimeNanos) implements PhysicalSignal {} +} diff --git a/src/main/java/dev/isxander/controlify/platform/ArrayBackedEventHandler.java b/src/main/java/dev/isxander/controlify/impl/platform/ArrayBackedEventHandler.java similarity index 91% rename from src/main/java/dev/isxander/controlify/platform/ArrayBackedEventHandler.java rename to src/main/java/dev/isxander/controlify/impl/platform/ArrayBackedEventHandler.java index 7235d782c..1ec13c73e 100644 --- a/src/main/java/dev/isxander/controlify/platform/ArrayBackedEventHandler.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/ArrayBackedEventHandler.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform; +package dev.isxander.controlify.impl.platform; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/dev/isxander/controlify/platform/Environment.java b/src/main/java/dev/isxander/controlify/impl/platform/Environment.java similarity index 78% rename from src/main/java/dev/isxander/controlify/platform/Environment.java rename to src/main/java/dev/isxander/controlify/impl/platform/Environment.java index c76124bc3..772ddd4ad 100644 --- a/src/main/java/dev/isxander/controlify/platform/Environment.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/Environment.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform; +package dev.isxander.controlify.impl.platform; public enum Environment { CLIENT, diff --git a/src/main/java/dev/isxander/controlify/platform/Event.java b/src/main/java/dev/isxander/controlify/impl/platform/Event.java similarity index 76% rename from src/main/java/dev/isxander/controlify/platform/Event.java rename to src/main/java/dev/isxander/controlify/impl/platform/Event.java index 35c1e70bd..9a38b539a 100644 --- a/src/main/java/dev/isxander/controlify/platform/Event.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/Event.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform; +package dev.isxander.controlify.impl.platform; public interface Event { } diff --git a/src/main/java/dev/isxander/controlify/platform/EventHandler.java b/src/main/java/dev/isxander/controlify/impl/platform/EventHandler.java similarity index 89% rename from src/main/java/dev/isxander/controlify/platform/EventHandler.java rename to src/main/java/dev/isxander/controlify/impl/platform/EventHandler.java index 8566a2fb1..bcd4311e2 100644 --- a/src/main/java/dev/isxander/controlify/platform/EventHandler.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/EventHandler.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform; +package dev.isxander.controlify.impl.platform; public interface EventHandler { void register(Callback event); diff --git a/src/main/java/dev/isxander/controlify/platform/client/CreativeTabHelper.java b/src/main/java/dev/isxander/controlify/impl/platform/client/CreativeTabHelper.java similarity index 89% rename from src/main/java/dev/isxander/controlify/platform/client/CreativeTabHelper.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/CreativeTabHelper.java index 0f7c35230..cf9b14dbd 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/CreativeTabHelper.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/CreativeTabHelper.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client; +package dev.isxander.controlify.impl.platform.client; import net.minecraft.world.item.CreativeModeTab; diff --git a/src/main/java/dev/isxander/controlify/platform/client/HudRenderLayer.java b/src/main/java/dev/isxander/controlify/impl/platform/client/HudRenderLayer.java similarity index 86% rename from src/main/java/dev/isxander/controlify/platform/client/HudRenderLayer.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/HudRenderLayer.java index 468194d54..67d6e1343 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/HudRenderLayer.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/HudRenderLayer.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client; +package dev.isxander.controlify.impl.platform.client; import net.minecraft.client.DeltaTracker; import net.minecraft.client.gui.GuiGraphicsExtractor; diff --git a/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtil.java b/src/main/java/dev/isxander/controlify/impl/platform/client/PlatformClientUtil.java similarity index 87% rename from src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtil.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/PlatformClientUtil.java index 26b5b97b6..54126f35b 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtil.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/PlatformClientUtil.java @@ -4,13 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client; +package dev.isxander.controlify.impl.platform.client; -import dev.isxander.controlify.platform.client.events.DisconnectedEvent; -import dev.isxander.controlify.platform.client.events.LifecycleEvent; -import dev.isxander.controlify.platform.client.events.ScreenRenderEvent; -import dev.isxander.controlify.platform.client.events.TickEvent; -import dev.isxander.controlify.platform.client.resource.ControlifyReloadListener; +import dev.isxander.controlify.impl.platform.client.events.DisconnectedEvent; +import dev.isxander.controlify.impl.platform.client.events.LifecycleEvent; +import dev.isxander.controlify.impl.platform.client.events.ScreenRenderEvent; +import dev.isxander.controlify.impl.platform.client.events.TickEvent; +import dev.isxander.controlify.impl.platform.client.resource.ControlifyReloadListener; import net.minecraft.client.KeyMapping; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.navigation.ScreenRectangle; diff --git a/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtilImpl.java b/src/main/java/dev/isxander/controlify/impl/platform/client/PlatformClientUtilImpl.java similarity index 90% rename from src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtilImpl.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/PlatformClientUtilImpl.java index 39d0deeb7..4b59befc4 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/PlatformClientUtilImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/PlatformClientUtilImpl.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client; +package dev.isxander.controlify.impl.platform.client; -import dev.isxander.controlify.platform.client.events.*; -import dev.isxander.controlify.platform.client.resource.ControlifyReloadListener; +import dev.isxander.controlify.impl.platform.client.events.*; +import dev.isxander.controlify.impl.platform.client.resource.ControlifyReloadListener; import net.minecraft.client.KeyMapping; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.navigation.ScreenRectangle; diff --git a/src/main/java/dev/isxander/controlify/platform/client/events/DisconnectedEvent.java b/src/main/java/dev/isxander/controlify/impl/platform/client/events/DisconnectedEvent.java similarity index 81% rename from src/main/java/dev/isxander/controlify/platform/client/events/DisconnectedEvent.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/events/DisconnectedEvent.java index a82216988..c355084f3 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/events/DisconnectedEvent.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/events/DisconnectedEvent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client.events; +package dev.isxander.controlify.impl.platform.client.events; import net.minecraft.client.Minecraft; diff --git a/src/main/java/dev/isxander/controlify/platform/client/events/LifecycleEvent.java b/src/main/java/dev/isxander/controlify/impl/platform/client/events/LifecycleEvent.java similarity index 81% rename from src/main/java/dev/isxander/controlify/platform/client/events/LifecycleEvent.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/events/LifecycleEvent.java index 82cad65c1..c72576056 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/events/LifecycleEvent.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/events/LifecycleEvent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client.events; +package dev.isxander.controlify.impl.platform.client.events; import net.minecraft.client.Minecraft; diff --git a/src/main/java/dev/isxander/controlify/platform/client/events/ScreenRenderEvent.java b/src/main/java/dev/isxander/controlify/impl/platform/client/events/ScreenRenderEvent.java similarity index 86% rename from src/main/java/dev/isxander/controlify/platform/client/events/ScreenRenderEvent.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/events/ScreenRenderEvent.java index 229a32215..d5e21459e 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/events/ScreenRenderEvent.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/events/ScreenRenderEvent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client.events; +package dev.isxander.controlify.impl.platform.client.events; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.screens.Screen; diff --git a/src/main/java/dev/isxander/controlify/platform/client/events/TickEvent.java b/src/main/java/dev/isxander/controlify/impl/platform/client/events/TickEvent.java similarity index 93% rename from src/main/java/dev/isxander/controlify/platform/client/events/TickEvent.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/events/TickEvent.java index ca04c021b..6df6bb8f2 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/events/TickEvent.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/events/TickEvent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client.events; +package dev.isxander.controlify.impl.platform.client.events; import net.minecraft.client.Minecraft; diff --git a/src/main/java/dev/isxander/controlify/platform/client/resource/ControlifyReloadListener.java b/src/main/java/dev/isxander/controlify/impl/platform/client/resource/ControlifyReloadListener.java similarity index 84% rename from src/main/java/dev/isxander/controlify/platform/client/resource/ControlifyReloadListener.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/resource/ControlifyReloadListener.java index 35e338da9..b1aeb615a 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/resource/ControlifyReloadListener.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/resource/ControlifyReloadListener.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client.resource; +package dev.isxander.controlify.impl.platform.client.resource; import net.minecraft.resources.Identifier; import net.minecraft.server.packs.resources.PreparableReloadListener; diff --git a/src/main/java/dev/isxander/controlify/platform/client/resource/SimpleControlifyReloadListener.java b/src/main/java/dev/isxander/controlify/impl/platform/client/resource/SimpleControlifyReloadListener.java similarity index 76% rename from src/main/java/dev/isxander/controlify/platform/client/resource/SimpleControlifyReloadListener.java rename to src/main/java/dev/isxander/controlify/impl/platform/client/resource/SimpleControlifyReloadListener.java index d99326e4c..aecea08da 100644 --- a/src/main/java/dev/isxander/controlify/platform/client/resource/SimpleControlifyReloadListener.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/client/resource/SimpleControlifyReloadListener.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.client.resource; +package dev.isxander.controlify.impl.platform.client.resource; import net.minecraft.server.packs.resources.ResourceManager; import org.jetbrains.annotations.NotNull; @@ -21,18 +21,9 @@ public interface SimpleControlifyReloadListener extends ControlifyReloadListe @NonNull PreparationBarrier preparationBarrier, @NonNull Executor applyExecutor ) { - return reload0(preparationBarrier, sharedState.resourceManager(), loadExecutor, applyExecutor); - } - - default @NotNull CompletableFuture reload0( - PreparationBarrier preparationBarrier, - ResourceManager resourceManager, - Executor loadExecutor, - Executor applyExecutor - ) { - return load(resourceManager, loadExecutor) + return load(sharedState.resourceManager(), loadExecutor) .thenCompose(preparationBarrier::wait) - .thenCompose(data -> apply(data, resourceManager, applyExecutor)); + .thenCompose(data -> apply(data, sharedState.resourceManager(), applyExecutor)); } /** diff --git a/src/main/java/dev/isxander/controlify/platform/main/PlatformMainUtil.java b/src/main/java/dev/isxander/controlify/impl/platform/main/PlatformMainUtil.java similarity index 64% rename from src/main/java/dev/isxander/controlify/platform/main/PlatformMainUtil.java rename to src/main/java/dev/isxander/controlify/impl/platform/main/PlatformMainUtil.java index b421b1d96..219e7be2e 100644 --- a/src/main/java/dev/isxander/controlify/platform/main/PlatformMainUtil.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/main/PlatformMainUtil.java @@ -4,15 +4,16 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.main; - -import dev.isxander.controlify.api.entrypoint.ControlifyEntrypoint; -import dev.isxander.controlify.platform.Environment; -import dev.isxander.controlify.platform.main.events.CommandRegistrationCallbackEvent; -import dev.isxander.controlify.platform.main.events.HandshakeCompletionEvent; -import dev.isxander.controlify.platform.main.events.PlayerJoinedEvent; -import dev.isxander.controlify.platform.network.C2SNetworkApi; -import dev.isxander.controlify.platform.network.S2CNetworkApi; +package dev.isxander.controlify.impl.platform.main; + +import dev.isxander.controlify.api.ControlifyEntrypoint; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.platform.Environment; +import dev.isxander.controlify.impl.platform.main.events.CommandRegistrationCallbackEvent; +import dev.isxander.controlify.impl.platform.main.events.HandshakeCompletionEvent; +import dev.isxander.controlify.impl.platform.main.events.PlayerJoinedEvent; +import dev.isxander.controlify.impl.platform.network.C2SNetworkApi; +import dev.isxander.controlify.impl.platform.network.S2CNetworkApi; import net.minecraft.core.Registry; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; @@ -22,12 +23,15 @@ import java.io.InputStream; import java.nio.file.Path; import java.util.Optional; +import java.util.HashSet; +import java.util.Set; import java.util.function.Consumer; import java.util.function.Supplier; public final class PlatformMainUtil { public static PlatformMainUtilImpl IMPL = null; + private static boolean invokedControlifyEntrypoints; public static void registerCommandRegistrationCallback(CommandRegistrationCallbackEvent callback) { IMPL.registerCommandRegistrationCallback(callback); @@ -45,6 +49,25 @@ public static Optional> getModFileInputStream(String mod return IMPL.getModFileInputStream(modId, path); } + /// Called by client bootstraps after installing both platform implementations. + public static void invokeControlifyEntrypoints() { + if (getEnv() != Environment.CLIENT || invokedControlifyEntrypoints) { + return; + } + invokedControlifyEntrypoints = true; + Set> invoked = new HashSet<>(); + applyToControlifyEntrypoint(entrypoint -> { + if (!invoked.add(entrypoint.getClass())) { + return; + } + try { + entrypoint.onControlifyInitialize(); + } catch (Throwable throwable) { + CUtil.LOGGER.error("Failed to initialize Controlify addon: {}", entrypoint.getClass().getName(), throwable); + } + }); + } + public static void applyToControlifyEntrypoint(Consumer entrypointConsumer) { IMPL.applyToControlifyEntrypoint(entrypointConsumer); } diff --git a/src/main/java/dev/isxander/controlify/platform/main/PlatformMainUtilImpl.java b/src/main/java/dev/isxander/controlify/impl/platform/main/PlatformMainUtilImpl.java similarity index 72% rename from src/main/java/dev/isxander/controlify/platform/main/PlatformMainUtilImpl.java rename to src/main/java/dev/isxander/controlify/impl/platform/main/PlatformMainUtilImpl.java index 09a116265..dfd191314 100644 --- a/src/main/java/dev/isxander/controlify/platform/main/PlatformMainUtilImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/main/PlatformMainUtilImpl.java @@ -4,15 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.main; - -import dev.isxander.controlify.api.entrypoint.ControlifyEntrypoint; -import dev.isxander.controlify.platform.Environment; -import dev.isxander.controlify.platform.main.events.CommandRegistrationCallbackEvent; -import dev.isxander.controlify.platform.main.events.HandshakeCompletionEvent; -import dev.isxander.controlify.platform.main.events.PlayerJoinedEvent; -import dev.isxander.controlify.platform.network.C2SNetworkApi; -import dev.isxander.controlify.platform.network.S2CNetworkApi; +package dev.isxander.controlify.impl.platform.main; + +import dev.isxander.controlify.api.ControlifyEntrypoint; +import dev.isxander.controlify.impl.platform.Environment; +import dev.isxander.controlify.impl.platform.main.events.CommandRegistrationCallbackEvent; +import dev.isxander.controlify.impl.platform.main.events.HandshakeCompletionEvent; +import dev.isxander.controlify.impl.platform.main.events.PlayerJoinedEvent; +import dev.isxander.controlify.impl.platform.network.C2SNetworkApi; +import dev.isxander.controlify.impl.platform.network.S2CNetworkApi; import net.minecraft.core.Registry; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; diff --git a/src/main/java/dev/isxander/controlify/platform/main/events/CommandRegistrationCallbackEvent.java b/src/main/java/dev/isxander/controlify/impl/platform/main/events/CommandRegistrationCallbackEvent.java similarity index 89% rename from src/main/java/dev/isxander/controlify/platform/main/events/CommandRegistrationCallbackEvent.java rename to src/main/java/dev/isxander/controlify/impl/platform/main/events/CommandRegistrationCallbackEvent.java index 405fef1ce..12d52d30d 100644 --- a/src/main/java/dev/isxander/controlify/platform/main/events/CommandRegistrationCallbackEvent.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/main/events/CommandRegistrationCallbackEvent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.main.events; +package dev.isxander.controlify.impl.platform.main.events; import com.mojang.brigadier.CommandDispatcher; import net.minecraft.commands.CommandBuildContext; diff --git a/src/main/java/dev/isxander/controlify/platform/main/events/HandshakeCompletionEvent.java b/src/main/java/dev/isxander/controlify/impl/platform/main/events/HandshakeCompletionEvent.java similarity index 86% rename from src/main/java/dev/isxander/controlify/platform/main/events/HandshakeCompletionEvent.java rename to src/main/java/dev/isxander/controlify/impl/platform/main/events/HandshakeCompletionEvent.java index ec4d107b7..374675f42 100644 --- a/src/main/java/dev/isxander/controlify/platform/main/events/HandshakeCompletionEvent.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/main/events/HandshakeCompletionEvent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.main.events; +package dev.isxander.controlify.impl.platform.main.events; import net.minecraft.server.network.ServerLoginPacketListenerImpl; import org.jetbrains.annotations.Nullable; diff --git a/src/main/java/dev/isxander/controlify/platform/main/events/PlayerJoinedEvent.java b/src/main/java/dev/isxander/controlify/impl/platform/main/events/PlayerJoinedEvent.java similarity index 81% rename from src/main/java/dev/isxander/controlify/platform/main/events/PlayerJoinedEvent.java rename to src/main/java/dev/isxander/controlify/impl/platform/main/events/PlayerJoinedEvent.java index 2bb5cc0f4..fc6c20196 100644 --- a/src/main/java/dev/isxander/controlify/platform/main/events/PlayerJoinedEvent.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/main/events/PlayerJoinedEvent.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.main.events; +package dev.isxander.controlify.impl.platform.main.events; import net.minecraft.server.level.ServerPlayer; diff --git a/src/main/java/dev/isxander/controlify/platform/network/C2SNetworkApi.java b/src/main/java/dev/isxander/controlify/impl/platform/network/C2SNetworkApi.java similarity index 92% rename from src/main/java/dev/isxander/controlify/platform/network/C2SNetworkApi.java rename to src/main/java/dev/isxander/controlify/impl/platform/network/C2SNetworkApi.java index 8870ac944..677fcde12 100644 --- a/src/main/java/dev/isxander/controlify/platform/network/C2SNetworkApi.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/network/C2SNetworkApi.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.network; +package dev.isxander.controlify.impl.platform.network; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; import net.minecraft.resources.Identifier; diff --git a/src/main/java/dev/isxander/controlify/platform/network/S2CNetworkApi.java b/src/main/java/dev/isxander/controlify/impl/platform/network/S2CNetworkApi.java similarity index 90% rename from src/main/java/dev/isxander/controlify/platform/network/S2CNetworkApi.java rename to src/main/java/dev/isxander/controlify/impl/platform/network/S2CNetworkApi.java index 97c935404..73315e5a7 100644 --- a/src/main/java/dev/isxander/controlify/platform/network/S2CNetworkApi.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/network/S2CNetworkApi.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.network; +package dev.isxander.controlify.impl.platform.network; import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerPlayer; diff --git a/src/main/java/dev/isxander/controlify/platform/network/SidedNetworkApi.java b/src/main/java/dev/isxander/controlify/impl/platform/network/SidedNetworkApi.java similarity index 81% rename from src/main/java/dev/isxander/controlify/platform/network/SidedNetworkApi.java rename to src/main/java/dev/isxander/controlify/impl/platform/network/SidedNetworkApi.java index 3987bf612..a1e9471ed 100644 --- a/src/main/java/dev/isxander/controlify/platform/network/SidedNetworkApi.java +++ b/src/main/java/dev/isxander/controlify/impl/platform/network/SidedNetworkApi.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.platform.network; +package dev.isxander.controlify.impl.platform.network; -import dev.isxander.controlify.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.Identifier; diff --git a/src/main/java/dev/isxander/controlify/reacharound/ReachAroundHandler.java b/src/main/java/dev/isxander/controlify/impl/reacharound/ReachAroundHandler.java similarity index 93% rename from src/main/java/dev/isxander/controlify/reacharound/ReachAroundHandler.java rename to src/main/java/dev/isxander/controlify/impl/reacharound/ReachAroundHandler.java index 44afd6814..76b15fdea 100644 --- a/src/main/java/dev/isxander/controlify/reacharound/ReachAroundHandler.java +++ b/src/main/java/dev/isxander/controlify/impl/reacharound/ReachAroundHandler.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.reacharound; +package dev.isxander.controlify.impl.reacharound; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.server.ServerPolicies; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.server.ServerPolicies; import net.minecraft.world.entity.Entity; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.HitResult; diff --git a/src/main/java/dev/isxander/controlify/reacharound/ReachAroundMode.java b/src/main/java/dev/isxander/controlify/impl/reacharound/ReachAroundMode.java similarity index 91% rename from src/main/java/dev/isxander/controlify/reacharound/ReachAroundMode.java rename to src/main/java/dev/isxander/controlify/impl/reacharound/ReachAroundMode.java index b714f4c19..1ba5f5e97 100644 --- a/src/main/java/dev/isxander/controlify/reacharound/ReachAroundMode.java +++ b/src/main/java/dev/isxander/controlify/impl/reacharound/ReachAroundMode.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.reacharound; +package dev.isxander.controlify.impl.reacharound; import com.mojang.serialization.Codec; -import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.impl.Controlify; import dev.isxander.yacl3.api.NameableEnum; import net.minecraft.client.Minecraft; import net.minecraft.network.chat.Component; @@ -22,8 +22,8 @@ public enum ReachAroundMode implements NameableEnum, StringRepresentable { //? if >=26.2 { minecraft.hasSingleplayerServer() //?} else { - /*minecraft.isSingleplayer() - *///?} + //minecraft.isSingleplayer() + //?} ), SINGLEPLAYER_AND_LAN((minecraft, controlify) -> minecraft.isLocalServer()), EVERYWHERE((minecraft, controlify) -> true); diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/BehaviourChain.java b/src/main/java/dev/isxander/controlify/impl/screenop/BehaviourChain.java new file mode 100644 index 000000000..8bb043464 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/BehaviourChain.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import java.util.List; +import java.util.function.BiConsumer; + +final class BehaviourChain { + private BehaviourChain() { + } + + static void run(List behaviours, BiConsumer invoke, Runnable terminal) { + run(behaviours, 0, invoke, terminal); + } + + private static void run(List behaviours, int index, BiConsumer invoke, Runnable terminal) { + if (index == behaviours.size()) { + terminal.run(); + return; + } + var next = new Continuation(() -> run(behaviours, index + 1, invoke, terminal)); + try { + invoke.accept(behaviours.get(index), next); + } finally { + next.active = false; + } + } + + private static final class Continuation implements Runnable { + private final Runnable operation; + private boolean active = true; + private boolean used; + + private Continuation(Runnable operation) { + this.operation = operation; + } + + @Override + public void run() { + if (!active || used) { + throw new IllegalStateException("A behaviour continuation must be called synchronously and at most once"); + } + used = true; + operation.run(); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/BehaviourRegistry.java b/src/main/java/dev/isxander/controlify/impl/screenop/BehaviourRegistry.java new file mode 100644 index 000000000..34305c981 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/BehaviourRegistry.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +/// Orders matching registrations by descending priority, then specificity, then +/// newest registration first. +/// Explicit assignability ordering also handles interfaces without relying on a +/// comparator whose ordering would be inconsistent for unrelated interfaces. +final class BehaviourRegistry { + private final List> registrations = new ArrayList<>(); + private boolean locked; + + void register(Class type, int priority, F factory) { + if (locked) { + throw new IllegalStateException("Behaviour registration has closed"); + } + registrations.add(new Registration<>(Objects.requireNonNull(type), priority, Objects.requireNonNull(factory))); + } + + List factories(T object) { + List> remaining = new ArrayList<>(); + for (int i = registrations.size() - 1; i >= 0; i--) { + var registration = registrations.get(i); + if (registration.type.isInstance(object)) { + remaining.add(registration); + } + } + // Stable sorting retains newest-first order within a priority. + remaining.sort(Comparator.>comparingInt(Registration::priority).reversed()); + List ordered = new ArrayList<>(remaining.size()); + while (!remaining.isEmpty()) { + for (int i = 0; i < remaining.size(); i++) { + var candidate = remaining.get(i); + boolean hasMoreSpecific = remaining.stream().anyMatch(other -> + candidate.priority == other.priority && candidate.type != other.type + && candidate.type.isAssignableFrom(other.type)); + if (!hasMoreSpecific) { + ordered.add(candidate.factory); + remaining.remove(i); + break; + } + } + } + return List.copyOf(ordered); + } + + void lock() { + locked = true; + } + + private record Registration(Class type, int priority, F factory) { + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/BuiltinScreenBehaviours.java b/src/main/java/dev/isxander/controlify/impl/screenop/BuiltinScreenBehaviours.java new file mode 100644 index 000000000..ac493cca1 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/BuiltinScreenBehaviours.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.isxander.controlify.api.gui.keyboard.*; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.compat.ControlifyCompat; +import dev.isxander.controlify.impl.gui.controllers.BindController; +import dev.isxander.controlify.impl.gui.keyboard.*; +import dev.isxander.controlify.impl.gui.screen.*; +import dev.isxander.controlify.impl.screenop.behaviours.element.*; +import dev.isxander.controlify.impl.screenop.behaviours.screen.*; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.*; +import dev.isxander.controlify.impl.screenop.behaviours.virtualmouse.*; +import dev.isxander.controlify.mixins.feature.screenop.impl.container.CreativeModeInventoryScreenMixin; +import dev.isxander.controlify.mixins.feature.screenop.impl.outofgame.*; +import dev.isxander.controlify.mixins.feature.screenop.runtime.SliderAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.*; +import net.minecraft.client.gui.components.events.*; +import net.minecraft.client.gui.screens.*; +import net.minecraft.client.gui.screens.inventory.*; +import net.minecraft.client.gui.screens.multiplayer.*; +import net.minecraft.client.gui.screens.options.*; +import net.minecraft.client.gui.screens.worldselection.*; +import net.minecraft.client.input.KeyEvent; + +import java.util.Optional; + +/// Built-ins use the same registration events as addons. Priorities order whole +/// groups; explicit chains define ordering within each group. +public final class BuiltinScreenBehaviours { + private BuiltinScreenBehaviours() { + } + + public static void initialize() { + ScreenopEvents.REGISTER_SCREEN_BEHAVIOURS.subscribe(BuiltinScreenBehaviours::registerScreens); + ScreenopEvents.REGISTER_ELEMENT_BEHAVIOURS.subscribe(BuiltinScreenBehaviours::registerElements); + ControlifyCompat.initializeScreenBehaviours(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void registerScreens(ScreenBehaviourRegistry registry) { + registry.register(Screen.class, 100, BuiltinScreenBehaviours::createCursorAndFocusBehaviours); + registry.register(Screen.class, -100, BuiltinScreenBehaviours::createDefaultControls); + registry.register(TitleScreen.class, TitleScreenBehaviour::new); + registry.register(SelectWorldScreen.class, SelectWorldScreenBehaviour::new); + registry.>register((Class) AbstractContainerScreen.class, context -> { + ((ScreenContextImpl) context).virtualMouse().setDefaultMode(VirtualMouseMode.CURSOR_SCROLL); + return new ContainerScreenBehaviour(context); + }); + registry.>register((Class) AbstractRecipeBookScreen.class, RecipeBookScreenBehaviour::new); + registry.register(CreativeModeInventoryScreen.class, CreativeModeInventoryScreenBehaviour::new); + registry.register(OptionsScreen.class, DoneButtonGuideBehaviour::new); + registry.register(OptionsSubScreen.class, DoneButtonGuideBehaviour::new); + registry.register(PauseScreen.class, PauseScreenBehaviour::new); + registry.register(JoinMultiplayerScreen.class, JoinMultiplayerScreenBehaviour::new); + registry.register(DirectJoinServerScreen.class, ServerAddressBehaviour::new); + registry.register(ManageServerScreen.class, ServerAddressBehaviour::new); + registry.register(CreateWorldScreen.class, CreateWorldScreenBehaviour::new); + registry.register(ChatScreen.class, ChatScreenBehaviour::new); + registry.register(AbstractSignEditScreen.class, SignEditingBehaviour::new); + registry.register(BindConsumerScreen.class, 200, InputCaptureBehaviour::new); + registry.register(ControllerMappingMakerScreen.class, 200, InputCaptureBehaviour::new); + registry.register(RadialMenuScreen.class, RadialMenuScreen.Behaviour::new); + registry.register(RadialMenuEditScreen.class, 200, RadialMenuEditScreen.Behaviour::new); + registry.register(ControlifySettingsScreen.class, ControlifySettingsScreen.Behaviour::new); + registry.register(ProfileSelectionScreen.class, ProfileSelectionScreen.Behaviour::new); + registry.register(EnchantmentScreen.class, AdditionalSnapPointsBehaviour::new); + registry.register(LoomScreen.class, AdditionalSnapPointsBehaviour::new); + registry.register(StonecutterScreen.class, AdditionalSnapPointsBehaviour::new); + } + + private static void registerElements(ScreenopEvents.RegisterElementBehavioursEvent event) { + var registry = event.registry(); + var context = event.screenContext(); + + // Snap eligibility -> custom behaviours (zero by default) -> traversal -> centre. + registry.register(AbstractWidget.class, 100, DefaultWidgetBehaviour::new); + registry.register(ContainerEventHandler.class, -100, DefaultContainerBehaviour::new); + registry.register(AbstractWidget.class, -200, ElementBehaviours::snapPoint); + registry.register(AbstractButton.class, ElementBehaviours.enabledWidget(ElementBehaviours.button( + button -> button.onPress(new KeyEvent(InputConstants.KEY_RETURN, 0, 0)), + button -> button.playDownSound(Minecraft.getInstance().getSoundManager()) + ))); + registry.register(AbstractSliderButton.class, ElementBehaviours.enabledWidget(ElementBehaviours.slider( + BuiltinScreenBehaviours::adjustSlider, + slider -> ((SliderAccessor) slider).controlify$canChangeValue(), + (slider, editing) -> ((SliderAccessor) slider).controlify$setCanChangeValue(editing) + ))); + registry.register(EditBox.class, DefaultEditBoxBehaviour::new); + registry.register(KeyWidget.class, key -> key); + registry.register(KeyboardWidget.class, keyboard -> keyboard); + + registry.register(BindController.BindControllerElement.class, element -> element); + registry.register(WorldSelectionList.WorldListEntry.class, ElementBehaviours.button(entry -> { + if (context.screen() instanceof SelectWorldScreen screen) { + screen.setFocused(((SelectWorldScreenAccessor) screen).controlify$getPlayWorldButton()); + } + })); + registry.register(ServerSelectionList.Entry.class, entry -> { + if (entry instanceof ServerSelectionList.LANHeader) { + return ElementBehaviours.chain(); + } + return ElementBehaviours.button(ignored -> { + if (context.screen() instanceof JoinMultiplayerScreen screen) { + screen.setFocused(((JoinMultiplayerScreenAccessor) screen).controlify$getJoinButton()); + } + }).create(entry); + }); + registerLanguageEntries(registry); + if (context.screen() instanceof ChatScreen) { + registry.register(EditBox.class, 10, box -> new ElementBehaviour() { + @Override + public Optional keyboardBehaviour(ScreenContext screen) { + return Optional.of(new ElementKeyboardBehaviour.DoNothing()); + } + }); + } + if (context.screen() instanceof ServerAddressScreenAccess address) { + registry.register(EditBox.class, 10, box -> ElementBehaviours.conditional( + () -> box == address.controlify$getAddressField(), + new DefaultEditBoxBehaviour(box, KeyboardLayouts.SERVER_IP, new EditBoxInputTarget(box)) + )); + } + if (context.screen() instanceof CreativeModeInventoryScreen screen) { + var access = (CreativeModeInventoryScreenMixin) screen; + registry.register(EditBox.class, 10, box -> { + var input = new DelegatingInputTarget(new EditBoxInputTarget(box)) { + @Override + public boolean acceptChar(char ch, int modifiers) { + boolean handled = super.acceptChar(ch, modifiers); + access.controlify$refreshSearchResults(); + return handled; + } + + @Override + public boolean acceptKeyCode(int keycode, int scancode, int modifiers) { + boolean handled = super.acceptKeyCode(keycode, scancode, modifiers); + access.controlify$refreshSearchResults(); + return handled; + } + }; + return ElementBehaviours.conditional(() -> box == access.controlify$getSearchBox(), + new DefaultEditBoxBehaviour(box, KeyboardLayouts.SIMPLE, input)); + }); + } + } + + private static void registerLanguageEntries(ElementBehaviourRegistry registry) { + try { + var type = Class.forName("net.minecraft.client.gui.screens.options.LanguageSelectScreen$LanguageSelectionList$Entry") + .asSubclass(GuiEventListener.class); + registry.register(type, LanguageSelectionBehaviour::new); + } catch (ClassNotFoundException exception) { + throw new IllegalStateException("Language selection entry class is unavailable", exception); + } + } + + private static void adjustSlider(AbstractSliderButton slider, int direction) { + var accessor = (SliderAccessor) slider; + boolean editing = accessor.controlify$canChangeValue(); + try { + // Secondary navigation adjusts without entering the widget's editing mode. + accessor.controlify$setCanChangeValue(true); + int key = direction < 0 ? InputConstants.KEY_LEFT : InputConstants.KEY_RIGHT; + slider.keyPressed(new KeyEvent(key, 0, 0)); + } finally { + accessor.controlify$setCanChangeValue(editing && slider.isFocused()); + } + } + + private static ScreenBehaviour createCursorAndFocusBehaviours(ScreenContext screen) { + var context = (ScreenContextImpl) screen; + var cursor = ScreenBehaviours.chain( + new CursorMovementBehaviour(context), + ScreenBehaviours.conditional( + () -> context.virtualMouse().mode().scrolls(), + new CursorScrollBehaviour(context) + ), + new CursorRenderBehaviour(context) + ); + + return ScreenBehaviours.chain( + new CursorModeBehaviour(context), + ScreenBehaviours.conditional(context::isVirtualMouseEnabled, cursor), + new FocusManagementBehaviour(context), + ScreenBehaviours.conditional( + () -> !context.isVirtualMouseEnabled(), + new FocusedElementBehaviour(context) + ) + ); + } + + private static ScreenBehaviour createDefaultControls(ScreenContext screen) { + var context = (ScreenContextImpl) screen; + return ScreenBehaviours.chain( + ScreenBehaviours.conditional( + () -> !context.isVirtualMouseEnabled(), + new FocusInputBehaviour(context) + ), + new TabNavigationBehaviour(context), + ScreenBehaviours.conditional( + () -> context.isVirtualMouseEnabled() && context.virtualMouse().mode().emulatesClicks(), + new CursorClickBehaviour(context) + ), + new BackButtonBehaviour(context), + new ScreenSnapPointBehaviour(context) + ); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/CompositeElementBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/CompositeElementBehaviour.java new file mode 100644 index 000000000..1082d6e68 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/CompositeElementBehaviour.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ElementKeyboardBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.SnapPoint; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import org.jspecify.annotations.NonNull; + +import java.util.List; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public final class CompositeElementBehaviour implements ElementBehaviour { + private final List behaviours; + private final boolean branch; + + public CompositeElementBehaviour(List behaviours) { + this(behaviours, false); + } + + private CompositeElementBehaviour(List behaviours, boolean branch) { + this.behaviours = List.copyOf(behaviours); + this.branch = branch; + } + + public static ElementBehaviour branch(ElementBehaviour behaviour) { + return new CompositeElementBehaviour(List.of(behaviour), true); + } + + private void run(BiConsumer invoke, Runnable next) { + if (branch) { + BehaviourChain.run(behaviours, invoke, () -> { + }); + next.run(); + } else { + BehaviourChain.run(behaviours, invoke, next); + } + } + + @Override + public void handleNavigation(@NonNull ScreenContext screen, @NonNull Controller controller, @NonNull Runnable next) { + run((behaviour, continuation) -> { + if (MinecraftUtil.getScreen() == screen.screen()) { + behaviour.handleNavigation(screen, controller, continuation); + } + }, () -> { + if (MinecraftUtil.getScreen() == screen.screen()) { + next.run(); + } + }); + } + + @Override + public void handleInteraction(@NonNull ScreenContext screen, @NonNull Controller controller, @NonNull Runnable next) { + run((behaviour, continuation) -> { + if (MinecraftUtil.getScreen() == screen.screen()) { + behaviour.handleInteraction(screen, controller, continuation); + } + }, () -> { + if (MinecraftUtil.getScreen() == screen.screen()) { + next.run(); + } + }); + } + + @Override + public void onFocusChanged(@NonNull ScreenContext screen, boolean focused) { + behaviours.forEach(behaviour -> behaviour.onFocusChanged(screen, focused)); + } + + @Override + public Optional keyboardBehaviour(@NonNull ScreenContext screen) { + return behaviours.stream().map(behaviour -> behaviour.keyboardBehaviour(screen)) + .flatMap(Optional::stream).findFirst(); + } + + @Override + public void collectSnapPoints(@NonNull ScreenContext screen, @NonNull Consumer output, @NonNull Runnable next) { + run((behaviour, continuation) -> behaviour.collectSnapPoints(screen, output, continuation), next); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/CompositeScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/CompositeScreenBehaviour.java new file mode 100644 index 000000000..710ffc718 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/CompositeScreenBehaviour.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.SnapPoint; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import org.jspecify.annotations.NonNull; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +public final class CompositeScreenBehaviour implements ScreenBehaviour { + private final List behaviours; + private final boolean branch; + + public CompositeScreenBehaviour(List behaviours) { + this(behaviours, false); + } + + private CompositeScreenBehaviour(List behaviours, boolean branch) { + this.behaviours = List.copyOf(behaviours); + this.branch = branch; + } + + public static ScreenBehaviour branch(ScreenBehaviour behaviour) { + return new CompositeScreenBehaviour(List.of(behaviour), true); + } + + private void run(BiConsumer invoke, Runnable next) { + if (branch) { + BehaviourChain.run(behaviours, invoke, () -> { + }); + next.run(); + } else { + BehaviourChain.run(behaviours, invoke, next); + } + } + + private void input(BiConsumer invoke, Runnable next) { + var screen = MinecraftUtil.getScreen(); + run((behaviour, continuation) -> { + if (MinecraftUtil.getScreen() == screen) { + invoke.accept(behaviour, continuation); + } + }, () -> { + if (MinecraftUtil.getScreen() == screen) { + next.run(); + } + }); + } + + @Override + public void handleNavigation(@NonNull Controller controller, @NonNull Runnable next) { + input((behaviour, continuation) -> behaviour.handleNavigation(controller, continuation), next); + } + + @Override + public void handleInteraction(@NonNull Controller controller, @NonNull Runnable next) { + input((behaviour, continuation) -> behaviour.handleInteraction(controller, continuation), next); + } + + @Override + public void onWidgetRebuild(@NonNull Runnable next) { + run(ScreenBehaviour::onWidgetRebuild, next); + } + + @Override + public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, float tickDelta, @NonNull Controller controller, @NonNull Runnable next) { + run((behaviour, continuation) -> behaviour.extractRenderState(graphics, tickDelta, controller, continuation), next); + } + + @Override + public void collectSnapPoints(@NonNull Consumer output, @NonNull Runnable next) { + run((behaviour, continuation) -> behaviour.collectSnapPoints(output, continuation), next); + } + + @Override + public void update(float delta, Runnable next) { + input((behaviour, continuation) -> behaviour.update(delta, continuation), next); + } + + @Override + public void onScreenActiveChanged(boolean active) { + behaviours.forEach(behaviour -> behaviour.onScreenActiveChanged(active)); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/ConditionalElementBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/ConditionalElementBehaviour.java new file mode 100644 index 000000000..6ae851b82 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/ConditionalElementBehaviour.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ElementKeyboardBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.SnapPoint; + +import java.util.Optional; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + +public class ConditionalElementBehaviour implements ElementBehaviour { + private final BooleanSupplier enabled; + private final ElementBehaviour behaviour; + + public ConditionalElementBehaviour(BooleanSupplier enabled, ElementBehaviour behaviour) { + this.enabled = enabled; + this.behaviour = behaviour; + } + + @Override + public void handleNavigation(ScreenContext screen, Controller controller, Runnable next) { + if (this.enabled.getAsBoolean()) { + this.behaviour.handleNavigation(screen, controller, next); + } else { + next.run(); + } + } + + @Override + public void handleInteraction(ScreenContext screen, Controller controller, Runnable next) { + if (this.enabled.getAsBoolean()) { + this.behaviour.handleInteraction(screen, controller, next); + } else { + next.run(); + } + } + + @Override + public void onFocusChanged(ScreenContext screen, boolean focused) { + this.behaviour.onFocusChanged(screen, focused); + } + + @Override + public Optional keyboardBehaviour(ScreenContext screen) { + if (this.enabled.getAsBoolean()) { + return this.behaviour.keyboardBehaviour(screen); + } else { + return Optional.empty(); + } + } + + @Override + public void collectSnapPoints(ScreenContext screen, Consumer output, Runnable next) { + if (this.enabled.getAsBoolean()) { + this.behaviour.collectSnapPoints(screen, output, next); + } else { + next.run(); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/ConditionalScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/ConditionalScreenBehaviour.java new file mode 100644 index 000000000..16e575317 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/ConditionalScreenBehaviour.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import net.minecraft.client.gui.GuiGraphicsExtractor; + +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + +/// Retains a stateful behaviour while deciding whether it participates in each call. +/// Lifetime notifications always reach the behaviour, including deactivation. +public final class ConditionalScreenBehaviour implements ScreenBehaviour { + private final BooleanSupplier enabled; + private final ScreenBehaviour behaviour; + + public ConditionalScreenBehaviour(BooleanSupplier enabled, ScreenBehaviour behaviour) { + this.enabled = enabled; + this.behaviour = behaviour; + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + if (enabled.getAsBoolean()) { + behaviour.handleNavigation(controller, next); + } else { + next.run(); + } + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (enabled.getAsBoolean()) { + behaviour.handleInteraction(controller, next); + } else { + next.run(); + } + } + + @Override + public void onWidgetRebuild(Runnable next) { + if (enabled.getAsBoolean()) { + behaviour.onWidgetRebuild(next); + } else { + next.run(); + } + } + + @Override + public void update(float delta, Runnable next) { + if (enabled.getAsBoolean()) { + behaviour.update(delta, next); + } else { + next.run(); + } + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta, Controller controller, Runnable next) { + if (enabled.getAsBoolean()) { + behaviour.extractRenderState(graphics, tickDelta, controller, next); + } else { + next.run(); + } + } + + @Override + public void collectSnapPoints(Consumer output, Runnable next) { + if (enabled.getAsBoolean()) { + behaviour.collectSnapPoints(output, next); + } else { + next.run(); + } + } + + @Override + public void onScreenActiveChanged(boolean active) { + behaviour.onScreenActiveChanged(active); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/ElementBehaviourRegistryImpl.java b/src/main/java/dev/isxander/controlify/impl/screenop/ElementBehaviourRegistryImpl.java new file mode 100644 index 000000000..63af4fe4d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/ElementBehaviourRegistryImpl.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ElementBehaviourFactory; +import dev.isxander.controlify.api.screenop.ElementBehaviourRegistry; +import dev.isxander.controlify.impl.screenop.behaviours.element.*; +import net.minecraft.client.gui.components.events.GuiEventListener; + +import java.util.List; +import java.util.Objects; + +final class ElementBehaviourRegistryImpl implements ElementBehaviourRegistry { + private final BehaviourRegistry> registry = new BehaviourRegistry<>(); + + @Override + public void register(Class elementClass, int priority, ElementBehaviourFactory factory) { + registry.register(elementClass, priority, factory); + } + + List create(GuiEventListener element) { + return registry.factories(element).stream().map(factory -> create(factory, element)).toList(); + } + + @SuppressWarnings("unchecked") + private static ElementBehaviour create(ElementBehaviourFactory factory, GuiEventListener element) { + return Objects.requireNonNull(factory.create((T) element), "Element behaviour factory returned null"); + } + + void lock() { + registry.lock(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/ScreenBehaviourRegistryImpl.java b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenBehaviourRegistryImpl.java new file mode 100644 index 000000000..b23e0cdfa --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenBehaviourRegistryImpl.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenBehaviourFactory; +import dev.isxander.controlify.api.screenop.ScreenBehaviourRegistry; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.ScreenopEvents; +import dev.isxander.controlify.impl.screenop.behaviours.screen.*; +import dev.isxander.controlify.impl.screenop.behaviours.virtualmouse.*; +import dev.isxander.controlify.impl.utils.event.EventBus; +import net.minecraft.client.gui.screens.Screen; + +import java.util.List; +import java.util.Objects; + +public final class ScreenBehaviourRegistryImpl implements ScreenBehaviourRegistry { + public static final class Events { + public static final EventBus REGISTER_SCREEN_BEHAVIOURS = new EventBus<>(); + public static final EventBus REGISTER_ELEMENT_BEHAVIOURS = new EventBus<>(); + + private Events() { + } + } + + public static final ScreenBehaviourRegistryImpl INSTANCE = new ScreenBehaviourRegistryImpl(); + + private final BehaviourRegistry> registry = new BehaviourRegistry<>(); + private boolean initialized; + private boolean ready; + + private ScreenBehaviourRegistryImpl() { + } + + public void initialize() { + if (initialized) { + throw new IllegalStateException("Screen behaviours have already been initialized"); + } + initialized = true; + try { + Events.REGISTER_SCREEN_BEHAVIOURS.emit(this); + ready = true; + } finally { + registry.lock(); + } + } + + @Override + public void register(Class screenClass, int priority, ScreenBehaviourFactory factory) { + registry.register(screenClass, priority, factory); + } + + public boolean isReady() { + return ready; + } + + List create(ScreenContext context) { + if (!ready) { + throw new IllegalStateException("Screen contexts are available after REGISTER_SCREEN_BEHAVIOURS completes"); + } + return registry.factories(context.screen()).stream() + .map(factory -> create(factory, context)) + .toList(); + } + + @SuppressWarnings("unchecked") + private static ScreenBehaviour create(ScreenBehaviourFactory factory, ScreenContext context) { + // The registry selected this factory using its registered screen class. + return Objects.requireNonNull(factory.create((ScreenContext) context), "Screen behaviour factory returned null"); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/ScreenContextHolder.java b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenContextHolder.java new file mode 100644 index 000000000..35fb9a6ed --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenContextHolder.java @@ -0,0 +1,16 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import org.jetbrains.annotations.Nullable; + +/// Storage only. All sidecar selection is performed through the registries. +public interface ScreenContextHolder { + ScreenContextImpl controlify$screenContext(); + + @Nullable ScreenContextImpl controlify$existingScreenContext(); +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/ScreenContextImpl.java b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenContextImpl.java new file mode 100644 index 000000000..ba4dae4fc --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenContextImpl.java @@ -0,0 +1,305 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import dev.isxander.controlify.api.InputMode; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuideApi; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.api.screenop.ElementKeyboardBehaviour; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.haptic.HapticEffects; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardOverlayScreen; +import dev.isxander.controlify.impl.sound.ControlifyClientSounds; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.mixins.feature.screenop.ScreenAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ComponentPath; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.ContainerEventHandler; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.sounds.SoundEvents; +import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; + +import java.util.*; + +/// One screen's state and dispatch. Default UI behaviour lives in the registered +/// composed screen and element behaviours, rather than in this context. +/// The screen retains this instance for its lifetime. +public final class ScreenContextImpl implements ScreenContext { + private final T screen; + private final VirtualMouseState virtualMouse; + private final ElementBehaviourRegistryImpl elements = new ElementBehaviourRegistryImpl(); + private final Map elementCache = new IdentityHashMap<>(); + private final Set constructingElements = identitySet(); + private final ScreenBehaviour behaviour; + private Set focusedElements = identitySet(); + private boolean elementsReady; + private boolean widgetsObserved; + + /// Resolves the same context across rebuilds and reopening the same screen. + @SuppressWarnings("unchecked") + public static ScreenContextImpl get(T screen) { + return (ScreenContextImpl) ((ScreenContextHolder) screen).controlify$screenContext(); + } + + public ScreenContextImpl(T screen) { + this.screen = Objects.requireNonNull(screen); + this.virtualMouse = new VirtualMouseState(this); + this.behaviour = ScreenBehaviours.chain(ScreenBehaviourRegistryImpl.INSTANCE.create(this)); + try { + ScreenBehaviourRegistryImpl.Events.REGISTER_ELEMENT_BEHAVIOURS.emit( + new ScreenopEvents.RegisterElementBehavioursEvent(this, elements) + ); + } finally { + elements.lock(); + } + elementsReady = true; + } + + @Override + public T screen() { + return screen; + } + + @Override + public ElementBehaviour elementBehaviour(GuiEventListener element) { + Objects.requireNonNull(element); + if (!elementsReady) { + throw new IllegalStateException("Element behaviours are available after REGISTER_ELEMENT_BEHAVIOURS completes"); + } + var cached = elementCache.get(element); + if (cached != null) { + return cached; + } + if (!constructingElements.add(element)) { + throw new IllegalStateException("Element behaviour factory recursively resolved its own element"); + } + try { + var chain = ElementBehaviours.chain(elements.create(element)); + elementCache.put(element, chain); + return chain; + } finally { + constructingElements.remove(element); + } + } + + @Override + public InputMode inputMode() { + return Controlify.instance().currentInputMode(); + } + + @Override + public VirtualMouseState virtualMouse() { + return virtualMouse; + } + + @Override + public List focusPath() { + List path = new ArrayList<>(); + Set visited = identitySet(); + GuiEventListener focused = screen.getFocused(); + while (focused != null && visited.add(focused)) { + path.addFirst(focused); + focused = focused instanceof ContainerEventHandler container ? container.getFocused() : null; + } + return List.copyOf(path); + } + + @Override + public void clearFocus() { + ((ScreenAccessor) screen).controlify$invokeClearFocus(); + synchronizeFocus(); + } + + @Override + public void setInitialFocus() { + ((ScreenAccessor) screen).invokeSetInitialFocus(); + synchronizeFocus(); + } + + @Override + public void notifyNavigation(Controller controller) { + if (controller instanceof ControllerImpl impl) { + impl.input().onGuiNavigate(); + } + if (Controlify.instance().config().getSettings().globalSettings().extraUiSounds) { + playFocusChangeSound(); + } + playNavigationHaptic(controller); + } + + @Override + public void playNavigationHaptic(Controller controller) { + if (controller instanceof ControllerImpl impl) { + impl.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE)); + } + } + + @Override + public void playFocusChangeSound() { + Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(ControlifyClientSounds.SCREEN_FOCUS_CHANGE.get(), 1f)); + } + + @Override + public void playClickSound() { + Minecraft.getInstance().getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1f)); + } + + @Override + public ButtonGuideApi buttonGuide() { + return dev.isxander.controlify.impl.gui.ButtonGuideApiImpl.INSTANCE; + } + + public void changeFocus(ComponentPath path) { + ((ScreenAccessor) screen).controlify$invokeChangeFocus(path); + synchronizeFocus(); + } + + @Override + public boolean tryOpenKeyboard(@NonNull Controller controller, @Nullable GuiEventListener element) { + if (element == null || element instanceof AbstractWidget widget && (!widget.active || !widget.visible)) { + return false; + } + var keyboard = elementBehaviour(element).keyboardBehaviour(this); + if (keyboard.isEmpty()) { + return false; + } + switch (keyboard.get()) { + case ElementKeyboardBehaviour.DoNothing() -> { + } + case ElementKeyboardBehaviour.Handled(var layout, var target, var positioner) -> { + if (((ControllerImpl) controller).settings().generic.keyboard.showOnScreenKeyboard) { + MinecraftUtil.setScreen(new KeyboardOverlayScreen(screen, layout, target, positioner)); + } + } + } + return true; + } + + /// Entry point for the host's fixed update. + public void handleControllerInput(Controller controller) { + if (!isCurrentScreen()) { + return; + } + observeWidgets(); + pruneElements(); + synchronizeFocus(); + behaviour.handleNavigation(controller, () -> { + }); + if (isCurrentScreen()) { + synchronizeFocus(); + behaviour.handleInteraction(controller, () -> { + }); + synchronizeFocus(); + } + } + + public void dispatchElements(Controller controller, boolean navigation, Runnable next) { + List path = focusPath().stream().map(this::elementBehaviour).toList(); + BehaviourChain.run(path, (element, continuation) -> { + if (isCurrentScreen()) { + if (navigation) { + element.handleNavigation(this, controller, continuation); + } else { + element.handleInteraction(this, controller, continuation); + } + } + }, next); + } + + public void onWidgetRebuild() { + widgetsObserved = true; + pruneElements(); + behaviour.onWidgetRebuild(() -> { + }); + synchronizeFocus(); + } + + public void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta, Controller controller) { + observeWidgets(); + behaviour.extractRenderState(graphics, tickDelta, controller, () -> { + }); + synchronizeFocus(); + } + + public Set collectSnapPoints() { + Set points = new LinkedHashSet<>(); + behaviour.collectSnapPoints(points::add, () -> { + }); + return Collections.unmodifiableSet(points); + } + + public void update(float delta) { + observeWidgets(); + behaviour.update(delta, () -> { + }); + synchronizeFocus(); + } + + public void onScreenActiveChanged(boolean active) { + behaviour.onScreenActiveChanged(active); + if (active) { + observeWidgets(); + } + synchronizeFocus(); + } + + private void observeWidgets() { + if (!widgetsObserved) { + onWidgetRebuild(); + } + } + + public void synchronizeFocus() { + if (!elementsReady) { + return; + } + Set previous = focusedElements; + Set current = identitySet(); + current.addAll(focusPath()); + focusedElements = current; + for (GuiEventListener element : previous) { + if (!current.contains(element)) { + elementBehaviour(element).onFocusChanged(this, false); + } + } + for (GuiEventListener element : current) { + if (!previous.contains(element)) { + elementBehaviour(element).onFocusChanged(this, true); + } + } + } + + private void pruneElements() { + Set attached = identitySet(); + ArrayDeque remaining = new ArrayDeque<>(screen.children()); + while (!remaining.isEmpty()) { + GuiEventListener element = remaining.removeFirst(); + if (attached.add(element) && element instanceof ContainerEventHandler container) { + remaining.addAll(container.children()); + } + } + attached.addAll(focusPath()); + // Notify lost focus while the old sidecar is still cached. + synchronizeFocus(); + elementCache.keySet().removeIf(element -> !attached.contains(element)); + } + + public boolean isCurrentScreen() { + return MinecraftUtil.getScreen() == screen; + } + + private static Set identitySet() { + return Collections.newSetFromMap(new IdentityHashMap<>()); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/ScreenRuntime.java b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenRuntime.java new file mode 100644 index 000000000..336432d3a --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenRuntime.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.mixins.feature.virtualmouse.MouseHandlerAccessor; +import net.minecraft.client.Minecraft; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +/// Host integration only: activates sidecars and dispatches frames. Cursor input +/// and rendering live in the registered screen behaviours. +public final class ScreenRuntime { + private static @Nullable ScreenContextImpl active; + private static boolean changing; + + private ScreenRuntime() { + } + + public static @Nullable ScreenContextImpl current() { + if (!ScreenBehaviourRegistryImpl.INSTANCE.isReady()) { + return null; + } + if ((active == null ? null : active.screen()) != MinecraftUtil.getScreen()) { + onScreenChanged(); + } + return active; + } + + public static Optional virtualMouse() { + return Optional.ofNullable(current()).map(ScreenContextImpl::virtualMouse); + } + + public static void onScreenChanged() { + if (changing || !ScreenBehaviourRegistryImpl.INSTANCE.isReady()) { + return; + } + if (active != null && active.screen() == MinecraftUtil.getScreen()) { + return; + } + changing = true; + try { + var previous = active; + active = null; + boolean hadCursor = previous != null && previous.virtualMouse().isEnabled(); + if (previous != null) { + previous.onScreenActiveChanged(false); + } + var screen = MinecraftUtil.getScreen(); + if (screen != null) { + active = ScreenContextImpl.get(screen); + active.onScreenActiveChanged(true); + if (Controlify.instance().currentInputMode().isController()) { + Controlify.instance().hideMouse(true, !active.virtualMouse().isEnabled()); + } + } else if (hadCursor) { + var mouse = Minecraft.getInstance().mouseHandler; + ((MouseHandlerAccessor) mouse).controlify$setMouseGrabbed(false); + mouse.grabMouse(); + } + } finally { + changing = false; + } + if ((active == null ? null : active.screen()) != MinecraftUtil.getScreen()) { + onScreenChanged(); + } + } + + public static void update() { + var context = current(); + if (context != null) { + context.update(Minecraft.getInstance().getDeltaTracker().getRealtimeDeltaTicks()); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/ScreenWidgets.java b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenWidgets.java new file mode 100644 index 000000000..7e4a1c92a --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/ScreenWidgets.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.ContainerEventHandler; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.contents.TranslatableContents; + +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Optional; +import java.util.function.Predicate; + +/// Finds live widgets after a screen has rebuilt them, including nested layouts. +public final class ScreenWidgets { + private ScreenWidgets() { + } + + public static Optional find(Screen screen, String translationKey) { + return find(screen, widget -> widget.getMessage().getContents() instanceof TranslatableContents contents + && contents.getKey().equals(translationKey)); + } + + public static Optional find(Screen screen, Component message) { + return find(screen, widget -> widget.getMessage().equals(message)); + } + + private static Optional find(Screen screen, Predicate predicate) { + var pending = new ArrayDeque(screen.children()); + var visited = Collections.newSetFromMap(new IdentityHashMap()); + while (!pending.isEmpty()) { + var element = pending.removeFirst(); + if (!visited.add(element)) { + continue; + } + if (element instanceof AbstractWidget widget && predicate.test(widget)) { + return Optional.of(widget); + } + if (element instanceof ContainerEventHandler container) { + pending.addAll(container.children()); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/VirtualMouseState.java b/src/main/java/dev/isxander/controlify/impl/screenop/VirtualMouseState.java new file mode 100644 index 000000000..5b57399ea --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/VirtualMouseState.java @@ -0,0 +1,389 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop; + +import com.mojang.blaze3d.platform.InputConstants; +import com.mojang.datafixers.util.Pair; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.mixins.feature.virtualmouse.MouseHandlerAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.navigation.ScreenAxis; +import net.minecraft.client.gui.navigation.ScreenDirection; +import net.minecraft.client.input.MouseButtonInfo; +import net.minecraft.util.Mth; +import org.jetbrains.annotations.Nullable; +import org.joml.*; + +import java.lang.Math; +import java.util.*; + +/// Shared cursor state and operations. Input interpretation, frame scheduling and +/// rendering are separate screen behaviours. +public final class VirtualMouseState implements VirtualMouse { + private final ScreenContextImpl context; + private final Minecraft minecraft = Minecraft.getInstance(); + private VirtualMouseMode mode = VirtualMouseMode.DEFAULT; + private boolean modeConfigured; + private boolean active; + private boolean enabled; + private boolean suspended; + private double targetX; + private double targetY; + private double currentX; + private double currentY; + private double scrollX; + private double scrollY; + private boolean leftClickDown; + private boolean rightClickDown; + private Set snapPoints = Set.of(); + private @Nullable SnapPoint lastSnappedPoint; + private final Vector2d previousMoveImpulse = new Vector2d(); + + VirtualMouseState(ScreenContextImpl context) { + this.context = context; + } + + @Override + public VirtualMouseMode mode() { + return mode; + } + + @Override + public void setMode(VirtualMouseMode mode) { + Objects.requireNonNull(mode); + modeConfigured = true; + if (this.mode != mode) { + releaseClicks(); + if (!mode.scrolls()) { + preventScrollingThisTick(); + } + } + this.mode = mode; + synchronizeMode(); + } + + /// A built-in default must not replace a mode explicitly selected by a more + /// specific factory, including an explicit selection of DEFAULT. + void setDefaultMode(VirtualMouseMode mode) { + if (!modeConfigured) { + setMode(mode); + } + } + + @Override + public boolean isEnabled() { + return enabled; + } + + public boolean isRequired() { + if (!Controlify.instance().currentInputMode().isController() || Controlify.instance().getCurrentController().isEmpty()) { + return false; + } + return switch (mode) { + case DISABLED -> false; + case ENABLED, CURSOR_ONLY, CURSOR_SCROLL -> true; + case DEFAULT -> Controlify.instance().config().getSettings().globalSettings().virtualMouseScreens + .stream().anyMatch(type -> type.isInstance(context.screen())); + }; + } + + public void activate() { + active = true; + suspended = false; + initializePosition(); + synchronizeMode(); + } + + public void deactivate() { + active = false; + setEnabled(false); + snapPoints = Set.of(); + lastSnappedPoint = null; + } + + public void setSuspended(boolean suspended) { + this.suspended = suspended; + synchronizeMode(); + } + + public void synchronizeMode() { + setEnabled(active && !suspended && isRequired()); + } + + private void setEnabled(boolean enabled) { + if (this.enabled == enabled) { + return; + } + this.enabled = enabled; + if (enabled) { + initializePosition(); + } else { + releaseClicks(); + scrollX = scrollY = 0; + previousMoveImpulse.zero(); + targetX = currentX; + targetY = currentY; + lastSnappedPoint = null; + } + } + + private void initializePosition() { + double x = minecraft.mouseHandler.xpos(), y = minecraft.mouseHandler.ypos(); + if (x == -50 && y == -50) { + x = minecraft.getWindow().getScreenWidth() / 2.0; + y = minecraft.getWindow().getScreenHeight() / 2.0; + } + currentX = targetX = x; + currentY = targetY = y; + } + + public Vector2d previousMoveImpulse() { + return new Vector2d(previousMoveImpulse); + } + + public void setMoveImpulse(Vector2d impulse) { + previousMoveImpulse.set(impulse); + } + + public void releaseUnlatchedClicks(Controller controller) { + if (leftClickDown && !ControlifyActions.VMOUSE_LCLICK.on(controller).latch().isLatchActive()) { + leftClickDown = false; + simulateMousePress(InputConstants.MOUSE_BUTTON_LEFT, InputConstants.RELEASE, 0); + } + if (rightClickDown && !ControlifyActions.VMOUSE_RCLICK.on(controller).latch().isLatchActive()) { + rightClickDown = false; + simulateMousePress(InputConstants.MOUSE_BUTTON_RIGHT, InputConstants.RELEASE, 0); + } + } + + public void refreshSnapPoints() { + snapPoints = context.collectSnapPoints(); + } + + public Set snapPoints() { + return snapPoints; + } + + public @Nullable SnapPoint lastSnappedPoint() { + return lastSnappedPoint; + } + + public double x() { + return currentX; + } + + public double y() { + return currentY; + } + + public void move(double x, double y) { + targetX = Mth.clamp(targetX + x, 0, minecraft.getWindow().getWidth()); + targetY = Mth.clamp(targetY + y, 0, minecraft.getWindow().getHeight()); + } + + public void updatePosition(float delta) { + double newX = Mth.lerp(Math.clamp(delta, 0, 1), currentX, targetX); + double newY = Mth.lerp(Math.clamp(delta, 0, 1), currentY, targetY); + if (newX != currentX || newY != currentY) { + double relX = newX - currentX, relY = newY - currentY; + currentX = newX; + currentY = newY; + ((MouseHandlerAccessor) minecraft.mouseHandler).controlify$invokeOnMove( + minecraft.getWindow().handle(), newX, newY /*? if >=26.3 {*/, relX, relY/*?}*/); + } + } + + public void addScroll(double y) { + scrollY += y; + } + + public void updateScroll(float delta) { + double y = scrollY * Math.clamp(delta, 0, 1), x = scrollX * Math.clamp(delta, 0, 1); + scrollY -= y; + scrollX -= x; + if (Math.abs(scrollY) < 0.01) { + scrollY = 0; + } + if (Math.abs(scrollX) < 0.01) { + scrollX = 0; + } + if (x != 0 || y != 0) { + ((MouseHandlerAccessor) minecraft.mouseHandler).controlify$invokeOnScroll(minecraft.getWindow().handle(), x, y); + } + } + + private void simulateMousePress(int button, int action, int modifiers) { + ((MouseHandlerAccessor) minecraft.mouseHandler).controlify$invokeOnButton( + minecraft.getWindow().handle(), new MouseButtonInfo(button, modifiers), action); + } + + public void releaseClicks() { + boolean left = leftClickDown, right = rightClickDown; + leftClickDown = rightClickDown = false; + if (left) { + simulateMousePress(InputConstants.MOUSE_BUTTON_LEFT, InputConstants.RELEASE, 0); + } + if (right) { + simulateMousePress(InputConstants.MOUSE_BUTTON_RIGHT, InputConstants.RELEASE, 0); + } + } + + @Override + public void emulateClicks(Controller controller) { + if (!isEnabled() || !context.isCurrentScreen()) { + return; + } + + if (ControlifyActions.VMOUSE_LCLICK.on(controller).pulse().consumeAllPulses()) { + leftClickDown = true; + this.simulateMousePress(InputConstants.MOUSE_BUTTON_LEFT, InputConstants.PRESS, 0); + } else if (leftClickDown && !ControlifyActions.VMOUSE_LCLICK.on(controller).latch().isLatchActive()) { + this.simulateMousePress(InputConstants.MOUSE_BUTTON_LEFT, InputConstants.RELEASE, 0); + leftClickDown = false; + } + + if (!isEnabled() || !context.isCurrentScreen()) { + return; + } + if (ControlifyActions.VMOUSE_RCLICK.on(controller).pulse().consumeAllPulses()) { + rightClickDown = true; + this.simulateMousePress(InputConstants.MOUSE_BUTTON_RIGHT, InputConstants.PRESS, 0); + } else if (rightClickDown && !ControlifyActions.VMOUSE_RCLICK.on(controller).latch().isLatchActive()) { + this.simulateMousePress(InputConstants.MOUSE_BUTTON_RIGHT, InputConstants.RELEASE, 0); + rightClickDown = false; + } + + while (isEnabled() && context.isCurrentScreen() && ControlifyActions.VMOUSE_SHIFT_CLICK.on(controller).pulse().consumePulse()) { + this.simulateMousePress(InputConstants.MOUSE_BUTTON_LEFT, InputConstants.PRESS, InputConstants.MOD_SHIFT); + this.simulateMousePress(InputConstants.MOUSE_BUTTON_LEFT, InputConstants.RELEASE, InputConstants.MOD_SHIFT); + } + } + + public void snapToClosestPoint() { + if (!enabled || !context.isCurrentScreen()) { + return; + } + refreshSnapPoints(); + var window = minecraft.getWindow(); + var scaleFactor = new Vector2d((double) window.getGuiScaledWidth() / (double) window.getScreenWidth(), (double) window.getGuiScaledHeight() / (double) window.getScreenHeight()); + var target = new Vector2d(targetX, targetY).mul(scaleFactor); + + if (lastSnappedPoint != null) { + if (lastSnappedPoint.position().distanceSquared(new Vector2i(target, RoundingMode.FLOOR)) > (long) lastSnappedPoint.range() * lastSnappedPoint.range()) { + lastSnappedPoint = null; + } + } + + var closestSnapPoint = snapPoints.stream() + .filter(snapPoint -> !snapPoint.equals(lastSnappedPoint)) // don't snap to the point currently over snapped point + .map(snapPoint -> new Pair<>(snapPoint, snapPoint.position().distanceSquared(new Vector2i(target, RoundingMode.FLOOR)))) // map with distance to current pos + .filter(point -> point.getSecond() <= (long) point.getFirst().range() * point.getFirst().range()) // filter out of range options + .min(Comparator.comparingLong(Pair::getSecond)) // find the closest point + .orElse(new Pair<>(null, Long.MAX_VALUE)).getFirst(); // retrieve point + + if (closestSnapPoint != null) { + snapToPoint(closestSnapPoint, scaleFactor); + } + } + + public void snapInDirection(ScreenDirection direction) { + if (!enabled || !context.isCurrentScreen()) { + return; + } + refreshSnapPoints(); + var window = minecraft.getWindow(); + var scaleFactor = new Vector2d((double) window.getGuiScaledWidth() / (double) window.getScreenWidth(), (double) window.getGuiScaledHeight() / (double) window.getScreenHeight()); + var target = new Vector2d(targetX, targetY).mul(scaleFactor); + + Optional closestSnapPoint = findOrthogonalSnapPoint(lastSnappedPoint, direction, target, snapPoints) + .or(() -> { + Vector2d orthogonalTarget = new Vector2d(target); + switch (direction) { + case UP -> orthogonalTarget.y = window.getGuiScaledHeight(); + case DOWN -> orthogonalTarget.y = 0; + case LEFT -> orthogonalTarget.x = window.getGuiScaledWidth(); + case RIGHT -> orthogonalTarget.x = 0; + } + + return findOrthogonalSnapPoint(lastSnappedPoint, direction, orthogonalTarget, snapPoints); + }); + + closestSnapPoint.ifPresent(snapPoint -> { + snapToPoint(snapPoint, scaleFactor); + }); + } + + private static Optional findOrthogonalSnapPoint(SnapPoint from, ScreenDirection direction, Vector2d target, Collection snapPoints) { + return snapPoints.stream() + .filter(snapPoint -> !snapPoint.equals(from)) // don't snap to the point currently over snapped point + .map(snapPoint -> new Pair<>(snapPoint, new Vector2d(snapPoint.position().x() - target.x(), snapPoint.position().y() - target.y()))) // map with distance to current pos + // filter points that are not in the correct direction + .filter(pair -> { + Vector2d dist = pair.getSecond(); + + double axis = direction.getAxis() == ScreenAxis.HORIZONTAL ? dist.x : dist.y; + double positive = direction.isPositive() ? 1 : -1; + + return axis * positive > 0; + }) + .filter(pair -> { + SnapPoint snapPoint = pair.getFirst(); + Vector2d dist = pair.getSecond(); + + // distance in the correct orthogonal direction + double distance = Math.abs(direction.getAxis() == ScreenAxis.HORIZONTAL ? dist.x : dist.y); + // distance in the incorrect orthogonal direction + double deviation = Math.abs(direction.getAxis() == ScreenAxis.HORIZONTAL ? dist.y : dist.x); + + // punish deviation significantly + pair.getSecond().set(distance, deviation * 4); + + // reject if the deviation is double the correct direction away + return distance >= snapPoint.range() && (deviation < distance * 2); + }) + // pick the closest point + .min(Comparator.comparingDouble(pair -> { + Vector2d distDev = pair.getSecond(); + // x = dist, y = deviation + return distDev.x + distDev.y; + })) + .map(Pair::getFirst); + } + + public void snapToPoint(SnapPoint snapPoint, Vector2dc scaleFactor) { + lastSnappedPoint = snapPoint; + + double newX = snapPoint.position().x() / scaleFactor.x(); + double newY = snapPoint.position().y() / scaleFactor.y(); + double relX = newX - currentX; + double relY = newY - currentY; + + var windowHandle = minecraft.getWindow().handle(); + ((MouseHandlerAccessor) minecraft.mouseHandler).controlify$invokeOnMove(windowHandle, newX, newY /*? if >=26.3 {*/, relX, relY/*?}*/); + + currentX = targetX = newX; + currentY = targetY = newY; + } + + @Override + public int getCurrentX(float deltaTime) { + return (int) Mth.lerp(deltaTime, currentX, targetX); + } + + @Override + public int getCurrentY(float deltaTime) { + return (int) Mth.lerp(deltaTime, currentY, targetY); + } + + @Override + public void preventScrollingThisTick() { + scrollX = scrollY = 0; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/ButtonBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/ButtonBehaviour.java new file mode 100644 index 000000000..df198b78c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/ButtonBehaviour.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.element; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.action.ControlifyActions; + +import java.util.function.Consumer; + +public final class ButtonBehaviour implements ElementBehaviour { + private final Runnable press; + private final Consumer> sound; + + public ButtonBehaviour(Runnable press) { + this(press, ScreenContext::playClickSound); + } + + public ButtonBehaviour(Runnable press, Runnable sound) { + this(press, screen -> sound.run()); + } + + private ButtonBehaviour(Runnable press, Consumer> sound) { + this.press = press; + this.sound = sound; + } + + @Override + public void handleInteraction(ScreenContext screen, Controller controller, Runnable next) { + if (!screen.isVirtualMouseEnabled() && ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses()) { + sound.accept(screen); + screen.playNavigationHaptic(controller); + press.run(); + return; + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/ControlValueAccess.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/ControlValueAccess.java new file mode 100644 index 000000000..4b64e7485 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/ControlValueAccess.java @@ -0,0 +1,12 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.element; + +/// Private adjustment hooks used by the shared slider and cycle behaviours. +public interface ControlValueAccess { + void controlify$adjustValue(int direction); +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/CycleBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/CycleBehaviour.java new file mode 100644 index 000000000..a5a2774ac --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/CycleBehaviour.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.element; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.utils.MinecraftUtil; + +import java.util.function.IntConsumer; + +/// Discrete options share secondary navigation and repeated activation semantics. +public final class CycleBehaviour implements ElementBehaviour { + private final IntConsumer cycle; + + public CycleBehaviour(IntConsumer cycle) { + this.cycle = cycle; + } + + @Override + public void handleNavigation(ScreenContext screen, Controller controller, Runnable next) { + if (screen.isVirtualMouseEnabled()) { + next.run(); + return; + } + boolean handled = consume(screen, controller, ControlifyActions.GUI_SECONDARY_NAVI_LEFT, -1); + handled |= consume(screen, controller, ControlifyActions.GUI_SECONDARY_NAVI_RIGHT, 1); + if (!handled) { + next.run(); + } + } + + @Override + public void handleInteraction(ScreenContext screen, Controller controller, Runnable next) { + if (screen.isVirtualMouseEnabled() || !consume(screen, controller, ControlifyActions.GUI_PRESS, 1)) { + next.run(); + } + } + + private boolean consume(ScreenContext screen, Controller controller, ActionAccessor action, int direction) { + boolean handled = false; + while (MinecraftUtil.getScreen() == screen.screen() && action.on(controller).pulse().consumePulse()) { + cycle.accept(direction); + screen.playNavigationHaptic(controller); + handled = true; + } + return handled; + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultContainerBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultContainerBehaviour.java new file mode 100644 index 000000000..d14f0af62 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultContainerBehaviour.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.element; + +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.SnapPoint; +import net.minecraft.client.gui.components.events.ContainerEventHandler; + +import java.util.List; +import java.util.function.Consumer; + +public final class DefaultContainerBehaviour implements ElementBehaviour { + private final ContainerEventHandler container; + + public DefaultContainerBehaviour(ContainerEventHandler container) { + this.container = container; + } + + @Override + public void collectSnapPoints(ScreenContext screen, Consumer output, Runnable next) { + for (var child : List.copyOf(container.children())) { + screen.elementBehaviour(child).collectSnapPoints(screen, output, () -> {}); + } + // Child targets replace the generic centre target of the container itself. + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultEditBoxBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultEditBoxBehaviour.java new file mode 100644 index 000000000..116bd7b79 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultEditBoxBehaviour.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.element; + +import dev.isxander.controlify.api.gui.keyboard.InputTarget; +import dev.isxander.controlify.api.gui.keyboard.KeyboardLayouts; +import dev.isxander.controlify.api.gui.keyboard.KeyboardPositioner; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ElementKeyboardBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.gui.keyboard.EditBoxInputTarget; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.resources.Identifier; + +import java.util.Optional; + +public final class DefaultEditBoxBehaviour implements ElementBehaviour { + private final EditBox editBox; + private final Identifier layout; + private final InputTarget inputTarget; + + public DefaultEditBoxBehaviour(EditBox editBox) { + this(editBox, KeyboardLayouts.SIMPLE, new EditBoxInputTarget(editBox)); + } + + public DefaultEditBoxBehaviour(EditBox editBox, Identifier layout, InputTarget inputTarget) { + this.editBox = editBox; + this.layout = layout; + this.inputTarget = inputTarget; + } + + @Override + public Optional keyboardBehaviour(ScreenContext screen) { + return Optional.of(new ElementKeyboardBehaviour.Handled( + layout, inputTarget, + KeyboardPositioner.aboveOrBelowWidgetPositioner( + (int) (screen.screen().width * 0.8f), (int) (screen.screen().height * 0.4f), + 1, editBox::getRectangle))); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultWidgetBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultWidgetBehaviour.java new file mode 100644 index 000000000..aebceb44d --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/DefaultWidgetBehaviour.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.element; + +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.SnapPoint; +import net.minecraft.client.gui.components.AbstractWidget; + +import java.util.function.Consumer; + +public final class DefaultWidgetBehaviour implements ElementBehaviour { + private final AbstractWidget widget; + + public DefaultWidgetBehaviour(AbstractWidget widget) { + this.widget = widget; + } + + @Override + public void collectSnapPoints(ScreenContext screen, Consumer output, Runnable next) { + if (widget.visible && widget.active) { + next.run(); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/LayoutElementSnapBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/LayoutElementSnapBehaviour.java new file mode 100644 index 000000000..ecbd70df6 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/LayoutElementSnapBehaviour.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.element; + +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.SnapPoint; +import net.minecraft.client.gui.layouts.LayoutElement; + +import java.util.function.Consumer; + +/// A centre target derived solely from layout geometry. Registration determines +/// which interactive element types use this behaviour. +public final class LayoutElementSnapBehaviour implements ElementBehaviour { + private final LayoutElement element; + + public LayoutElementSnapBehaviour(LayoutElement element) { + this.element = element; + } + + @Override + public void collectSnapPoints(ScreenContext screen, Consumer output, Runnable next) { + output.accept(new SnapPoint( + element.getX() + element.getWidth() / 2, + element.getY() + element.getHeight() / 2, + Math.min(element.getWidth(), element.getHeight()) / 2 + 10 + )); + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/SliderBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/SliderBehaviour.java new file mode 100644 index 000000000..d09542396 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/SliderBehaviour.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.element; + +import dev.isxander.controlify.api.action.ActionAccessor; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.utils.MinecraftUtil; + +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.IntConsumer; + +public final class SliderBehaviour implements ElementBehaviour { + private final IntConsumer adjust; + private final BooleanSupplier isEditing; + private final Consumer setEditing; + private boolean editing; + private boolean toggleEditing = true; + + public SliderBehaviour(IntConsumer adjust) { + this.adjust = adjust; + this.isEditing = () -> editing; + this.setEditing = editing -> this.editing = editing; + } + + public static SliderBehaviour secondaryNavigation(IntConsumer adjust) { + var behaviour = new SliderBehaviour(adjust); + behaviour.toggleEditing = false; + return behaviour; + } + + public SliderBehaviour(IntConsumer adjust, BooleanSupplier isEditing, Consumer setEditing) { + this.adjust = adjust; + this.isEditing = isEditing; + this.setEditing = setEditing; + } + + @Override + public void handleNavigation(ScreenContext screen, Controller controller, Runnable next) { + if (screen.isVirtualMouseEnabled()) { + next.run(); + return; + } + + boolean handled = adjustPulses(screen, controller, ControlifyActions.GUI_SECONDARY_NAVI_LEFT, -1); + handled |= adjustPulses(screen, controller, ControlifyActions.GUI_SECONDARY_NAVI_RIGHT, 1); + if (isEditing.getAsBoolean()) { + handled |= adjustPulses(screen, controller, ControlifyActions.GUI_NAVI_LEFT, -1); + handled |= adjustPulses(screen, controller, ControlifyActions.GUI_NAVI_RIGHT, 1); + } + if (!handled) { + next.run(); + } + } + + private boolean adjustPulses(ScreenContext screen, Controller controller, ActionAccessor action, int direction) { + boolean handled = false; + while (MinecraftUtil.getScreen() == screen.screen() && action.on(controller).pulse().consumePulse()) { + adjust.accept(direction); + screen.playNavigationHaptic(controller); + handled = true; + } + return handled; + } + + @Override + public void handleInteraction(ScreenContext screen, Controller controller, Runnable next) { + if (!toggleEditing || screen.isVirtualMouseEnabled()) { + next.run(); + return; + } + if (isEditing.getAsBoolean() && ControlifyActions.GUI_BACK.on(controller).pulse().consumeAllPulses()) { + // Back wins if both were pressed, so Press cannot immediately reopen editing. + ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses(); + setEditing.accept(false); + return; + } + if (ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses()) { + setEditing.accept(!isEditing.getAsBoolean()); + screen.playNavigationHaptic(controller); + return; + } + next.run(); + } + + @Override + public void onFocusChanged(ScreenContext screen, boolean focused) { + setEditing.accept(false); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/package-info.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/package-info.java new file mode 100644 index 000000000..f5d4fb63b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/element/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.impl.screenop.behaviours.element; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/AdditionalSnapPointsBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/AdditionalSnapPointsBehaviour.java new file mode 100644 index 000000000..fd3c88c0b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/AdditionalSnapPointsBehaviour.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.screen; + +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.SnapPoint; + +import java.util.function.Consumer; + +public final class AdditionalSnapPointsBehaviour implements ScreenBehaviour { + private final Access access; + + public AdditionalSnapPointsBehaviour(ScreenContext context) { + this.access = (Access) context.screen(); + } + + @Override + public void collectSnapPoints(Consumer consumer, Runnable next) { + next.run(); + this.access.controlify$collectSnapPoints(consumer); + } + + public interface Access { + void controlify$collectSnapPoints(Consumer consumer); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/BackButtonBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/BackButtonBehaviour.java new file mode 100644 index 000000000..efe2f16db --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/BackButtonBehaviour.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.screen; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; + +public final class BackButtonBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + + public BackButtonBehaviour(ScreenContextImpl context) { + this.context = context; + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (context.screen().shouldCloseOnEsc() && ControlifyActions.GUI_BACK.on(controller).pulse().consumeAllPulses()) { + context.playClickSound(); + context.screen().onClose(); + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusInputBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusInputBehaviour.java new file mode 100644 index 000000000..db47db630 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusInputBehaviour.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.screen; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import net.minecraft.client.InputType; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.navigation.FocusNavigationEvent; +import net.minecraft.client.gui.navigation.ScreenDirection; +import net.minecraft.client.input.KeyEvent; + +import java.util.List; + +/// Navigation and activation through the focused element path. +public final class FocusInputBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + + public FocusInputBehaviour(ScreenContextImpl context) { + this.context = context; + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + if (context.screen().getFocused() == null) { + context.setInitialFocus(); + } + for (ScreenDirection direction : List.of(ScreenDirection.RIGHT, ScreenDirection.LEFT, ScreenDirection.UP, ScreenDirection.DOWN)) { + var action = switch (direction) { + case RIGHT -> ControlifyActions.GUI_NAVI_RIGHT; + case LEFT -> ControlifyActions.GUI_NAVI_LEFT; + case UP -> ControlifyActions.GUI_NAVI_UP; + case DOWN -> ControlifyActions.GUI_NAVI_DOWN; + }; + while (context.isCurrentScreen() && action.on(controller).pulse().consumePulse()) { + var path = context.screen().nextFocusPath(new FocusNavigationEvent.ArrowNavigation(direction)); + if (path != null) { + Minecraft.getInstance().setLastInputType(InputType.KEYBOARD_ARROW); + context.changeFocus(path); + context.notifyNavigation(controller); + } + } + } + next.run(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses()) { + var focused = context.focusPath(); + // A widget can be disabled or hidden while it still holds focus. Do not + // reactivate it through the generic keyboard path after its behaviour skips. + boolean unavailable = focused.stream().anyMatch(element -> + element instanceof AbstractWidget widget && (!widget.active || !widget.visible)); + if (!unavailable && !context.tryOpenKeyboard(controller, focused.isEmpty() ? null : focused.getFirst())) { + context.screen().keyPressed(new KeyEvent(InputConstants.KEY_RETURN, 0, 0)); + } + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusManagementBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusManagementBehaviour.java new file mode 100644 index 000000000..82b9e2d60 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusManagementBehaviour.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.screen; + +import dev.isxander.controlify.api.InputMode; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import net.minecraft.client.InputType; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.EditBox; +import org.jetbrains.annotations.Nullable; + +public final class FocusManagementBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + private @Nullable InputMode previousMode; + private boolean previousVirtualMouse; + + public FocusManagementBehaviour(ScreenContextImpl context) { + this.context = context; + } + + @Override + public void update(float delta, Runnable next) { + synchronizeInputMode(); + next.run(); + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + synchronizeInputMode(); + next.run(); + } + + @Override + public void onScreenActiveChanged(boolean active) { + if (active) { + previousMode = null; + synchronizeInputMode(); + } + } + + @Override + public void onWidgetRebuild(Runnable next) { + if (context.inputMode().isController() && !context.isVirtualMouseEnabled()) { + Minecraft.getInstance().setLastInputType(InputType.KEYBOARD_ARROW); + } + previousMode = null; + synchronizeInputMode(); + next.run(); + } + + private void synchronizeInputMode() { + if (!context.isCurrentScreen()) { + return; + } + InputMode mode = context.inputMode(); + boolean mouse = context.isVirtualMouseEnabled(); + if (mode == previousMode && mouse == previousVirtualMouse) { + return; + } + previousMode = mode; + previousVirtualMouse = mouse; + if (mouse || mode == InputMode.KEYBOARD_MOUSE) { + if (mouse || context.focusPath().stream().noneMatch(EditBox.class::isInstance)) { + context.clearFocus(); + } + } else if (context.screen().getFocused() == null) { + context.setInitialFocus(); + } + } + +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusedElementBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusedElementBehaviour.java new file mode 100644 index 000000000..0d2bf7cc4 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/FocusedElementBehaviour.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.screen; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; + +public final class FocusedElementBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + + public FocusedElementBehaviour(ScreenContextImpl context) { + this.context = context; + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + context.dispatchElements(controller, true, next); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + context.dispatchElements(controller, false, next); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/ScreenSnapPointBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/ScreenSnapPointBehaviour.java new file mode 100644 index 000000000..ac473ff3c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/ScreenSnapPointBehaviour.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.screen; + +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; + +import java.util.List; +import java.util.function.Consumer; + +public final class ScreenSnapPointBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + + public ScreenSnapPointBehaviour(ScreenContextImpl context) { + this.context = context; + } + + @Override + public void collectSnapPoints(Consumer output, Runnable next) { + for (var element : List.copyOf(context.screen().children())) { + context.elementBehaviour(element).collectSnapPoints(context, output, () -> {}); + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/TabNavigationBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/TabNavigationBehaviour.java new file mode 100644 index 000000000..45f931abf --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/TabNavigationBehaviour.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.screen; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import dev.isxander.controlify.mixins.feature.screenop.impl.outofgame.TabNavigationBarAccessor; +import net.minecraft.client.gui.components.tabs.TabNavigationBar; + +import java.util.Optional; + +public final class TabNavigationBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + + public TabNavigationBehaviour(ScreenContextImpl context) { + this.context = context; + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + for (int direction : new int[]{1, -1}) { + var action = direction == 1 ? ControlifyActions.GUI_NEXT_TAB : ControlifyActions.GUI_PREV_TAB; + while (context.isCurrentScreen() && action.on(controller).pulse().consumePulse()) { + Optional navBar = context.screen().children() + .stream() + .filter(TabNavigationBar.class::isInstance) + .map(TabNavigationBar.class::cast) + .findFirst(); + if (navBar.isEmpty()) { + break; + } + var accessor = (TabNavigationBarAccessor) navBar.get(); + var tabs = accessor.controlify$getTabs(); + if (!tabs.isEmpty()) { + int current = tabs.indexOf(accessor.controlify$getTabManager().getCurrentTab()); + navBar.get().selectTab(Math.floorMod(current + direction, tabs.size()), true); + context.synchronizeFocus(); + } + } + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/package-info.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/package-info.java new file mode 100644 index 000000000..8117096d4 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/screen/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.impl.screenop.behaviours.screen; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/BundleItemSlotControllerAction.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/BundleItemSlotControllerAction.java new file mode 100644 index 000000000..7b015f666 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/BundleItemSlotControllerAction.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import net.minecraft.client.gui.navigation.ScreenDirection; +import net.minecraft.util.Mth; +import net.minecraft.world.item.BundleItem; +import net.minecraft.world.item.ItemStack; + +public class BundleItemSlotControllerAction { + public static boolean onControllerInput( + ItemStack stack, + int hoveredSlotIndex, + ControllerImpl controller, + SelectedBundleItemConsumer consumer + ) { + int uniqueItems = BundleItem.getNumberOfItemsToShow(stack); + + if (uniqueItems > 0) { + dev.isxander.controlify.impl.screenop.ScreenRuntime.virtualMouse().ifPresent(mouse -> mouse.preventScrollingThisTick()); + + boolean handled = false; + for (var direction : ScreenDirection.values()) { + var action = switch (direction) { + case UP -> ControlifyActions.GUI_SECONDARY_NAVI_UP; + case DOWN -> ControlifyActions.GUI_SECONDARY_NAVI_DOWN; + case LEFT -> ControlifyActions.GUI_SECONDARY_NAVI_LEFT; + case RIGHT -> ControlifyActions.GUI_SECONDARY_NAVI_RIGHT; + }; + while (action.on(controller).pulse().consumePulse()) { + int offsetX = direction == ScreenDirection.LEFT ? -1 + : direction == ScreenDirection.RIGHT ? 1 : 0; + int offsetY = direction == ScreenDirection.UP ? -1 + : direction == ScreenDirection.DOWN ? 1 : 0; + handled |= moveSelection(stack, hoveredSlotIndex, consumer, uniqueItems, offsetX, offsetY); + } + } + return handled; + } + + return false; + } + + private static boolean moveSelection(ItemStack stack, int hoveredSlotIndex, SelectedBundleItemConsumer consumer, int uniqueItems, int offsetX, int offsetY) { + int currentIndex = BundleItem.getSelectedItemIndex(stack); + + if (currentIndex == -1) { + consumer.accept(stack, hoveredSlotIndex, 0); + return true; + } + + /* + Bundle grid is organised where the first row is the incomplete row and is RTL, + - - 0 1 + 2 3 4 5 + */ + int rowSize = BundleItem.MAX_SHOWN_GRID_ITEMS_X; + int colSize = Math.min(Mth.ceil(uniqueItems / (float) rowSize), BundleItem.MAX_SHOWN_GRID_ITEMS_Y); + int incompleteRowSize = uniqueItems % rowSize; + int emptySlots = (rowSize - incompleteRowSize) % rowSize; + + int gridX = (currentIndex + emptySlots) % rowSize; + int gridY = (currentIndex + emptySlots) / rowSize; + + int newGridX = (gridX + offsetX + rowSize) % rowSize; + int newGridY = (gridY + offsetY + colSize) % colSize; + + if (newGridY >= 0 && newGridY < colSize) { + int newIndex = Math.max(newGridX + newGridY * rowSize - emptySlots, 0); + + consumer.accept(stack, hoveredSlotIndex, newIndex); + } + + return true; + } + + public interface SelectedBundleItemConsumer { + void accept(ItemStack stack, int hoveredSlot, int selectedIndex); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ChatScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ChatScreenBehaviour.java new file mode 100644 index 000000000..2f3e8fafe --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ChatScreenBehaviour.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.gui.keyboard.CommonKeyboardHints; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardWidget; +import dev.isxander.controlify.impl.utils.LazyComponentDims; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.ChatScreen; +import net.minecraft.network.chat.Component; +import org.jetbrains.annotations.Nullable; + +public final class ChatScreenBehaviour implements ScreenBehaviour { + private final Access access; + private final ScreenContext context; + private final ChatScreen screen; + private final Minecraft minecraft = Minecraft.getInstance(); + + private final LazyComponentDims commandSuggesterHint = new LazyComponentDims( + Component.translatable("controlify.hint.command_suggester", + ActionGlyphs.action(ControlifyActions.GUI_SECONDARY_NAVI_DOWN.actionId()), + ActionGlyphs.action(ControlifyActions.GUI_SECONDARY_NAVI_UP.actionId()), + ActionGlyphs.action(ControlifyActions.GUI_SECONDARY_NAVI_RIGHT.actionId())) + ); + + public ChatScreenBehaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + this.access = (Access) context.screen(); + } + + @Override + public void handleInteraction(Controller input, Runnable next) { + var controller = (ControllerImpl) input; + + CmdSuggestionsController suggestionsController = access.controlify$getSuggestionsController(); + if (suggestionsController != null) { + while (ControlifyActions.GUI_SECONDARY_NAVI_DOWN.on(controller).pulse().consumePulse()) { + if (suggestionsController.controlify$cycle(1)) { + + context.playFocusChangeSound(); + + this.clearCommandSuggesterHint(controller); + } + } + while (ControlifyActions.GUI_SECONDARY_NAVI_UP.on(controller).pulse().consumePulse()) { + if (suggestionsController.controlify$cycle(-1)) { + + context.playFocusChangeSound(); + + this.clearCommandSuggesterHint(controller); + } + } + while (ControlifyActions.GUI_SECONDARY_NAVI_RIGHT.on(controller).pulse().consumePulse()) { + if (suggestionsController.controlify$useSuggestion()) { + + context.playClickSound(); + + this.clearCommandSuggesterHint(controller); + } + } + } + next.run(); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta, Controller input, Runnable next) { + var controller = (ControllerImpl) input; + var settings = controller.settings().generic; + + if (access.controlify$getKeyboard() != null && settings.guide.showScreenGuides) { + if (settings.keyboard.hintCursor) { + LazyComponentDims hint = CommonKeyboardHints.TEXT_CURSOR; + + int x = access.controlify$getInput().getRight() - hint.getWidth() - 2; + int y = access.controlify$getInput().getY() - hint.getHeight(); + + graphics.text(minecraft.font, hint.getComponent(), x, y, 0xFFFFFFFF, true); + } + + if (settings.keyboard.hintCommandSuggester) { + CmdSuggestionsController suggestionsController = access.controlify$getSuggestionsController(); + + if (suggestionsController != null && suggestionsController.controlify$hasAvailableSuggestions()) { + LazyComponentDims hint = this.commandSuggesterHint; + + int x = this.screen.width - hint.getWidth() - 2; + int y = 2 + Math.max(0, hint.getHeight() - minecraft.font.lineHeight); + + graphics.text(minecraft.font, hint.getComponent(), x, y, 0xFFFFFFFF, true); + } + } + } + next.run(); + } + + private void clearCommandSuggesterHint(ControllerImpl controller) { + var settings = controller.settings().generic; + if (settings.keyboard.hintCommandSuggester && settings.guide.showScreenGuides) { + settings.keyboard.hintCommandSuggester = false; + Controlify.instance().config().saveSafely(); + } + } + + public interface CmdSuggestionsController { + boolean controlify$cycle(int amount); + + boolean controlify$useSuggestion(); + + boolean controlify$hasAvailableSuggestions(); + } + + public interface Access { + EditBox controlify$getInput(); + + @Nullable KeyboardWidget controlify$getKeyboard(); + + @Nullable CmdSuggestionsController controlify$getSuggestionsController(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ContainerScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ContainerScreenBehaviour.java new file mode 100644 index 000000000..fe9f03067 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ContainerScreenBehaviour.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.contextual.ContainerContext; +import dev.isxander.controlify.api.contextual.GuideInstance; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.api.screenop.SnapPoint; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.contextual.ContextualDomains; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.gui.guide.GuideRenderer; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.mixins.feature.guide.screen.AbstractContainerScreenAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.world.inventory.ContainerInput; +import net.minecraft.world.inventory.Slot; +import org.jetbrains.annotations.Nullable; + +import java.util.function.Consumer; + +/// Inventory interaction, slot snap targets and the contextual inventory guide. +/// Recipe books and creative tabs add independent behaviours to this one. +public final class ContainerScreenBehaviour implements ScreenBehaviour { + private final ScreenContext> context; + private final AbstractContainerScreen screen; + private final Access access; + private final AbstractContainerScreenAccessor geometry; + private final GuideInstance guide; + private final GuideRenderer.Renderable guideRenderer; + private @Nullable Slot previousSlot; + + public ContainerScreenBehaviour(ScreenContext> context) { + this.context = context; + this.screen = context.screen(); + this.access = (Access) screen; + this.geometry = (AbstractContainerScreenAccessor) screen; + this.guide = ContextualDomains.INSTANCE.container().createGuideInstance(Minecraft.getInstance().font); + this.guideRenderer = (GuideRenderer.Renderable) guide.renderable(true, false); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (!context.isVirtualMouseEnabled()) { + next.run(); + return; + } + var slot = geometry.controlify$getHoveredSlot(); + if (slot != null) { + if (slot.hasItem() && access.controlify$handleItemSlotActions((ControllerImpl) controller)) { + return; + } + if (ControlifyActions.INV_SELECT.on(controller).pulse().consumeAllPulses()) { + access.controlify$clickSlot(slot, slot.index, 0, ContainerInput.PICKUP); + context.playNavigationHaptic(controller); + } + while (MinecraftUtil.getScreen() == screen && ControlifyActions.INV_QUICK_MOVE.on(controller).pulse().consumePulse()) { + access.controlify$clickSlot(slot, slot.index, 0, ContainerInput.QUICK_MOVE); + context.playNavigationHaptic(controller); + } + if (ControlifyActions.INV_TAKE_HALF.on(controller).pulse().consumeAllPulses()) { + access.controlify$clickSlot(slot, slot.index, 1, ContainerInput.PICKUP); + context.playNavigationHaptic(controller); + } + } else { + context.virtualMouse().emulateClicks(controller); + } + if (MinecraftUtil.getScreen() == screen && !screen.getMenu().getCarried().isEmpty() + && ControlifyActions.DROP_INVENTORY.on(controller).pulse().consumeAllPulses()) { + access.controlify$clickSlot(null, -999, 0, ContainerInput.PICKUP); + context.playNavigationHaptic(controller); + } + next.run(); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta, Controller controller, Runnable next) { + next.run(); + if (MinecraftUtil.getScreen() != screen || !context.inputMode().isController()) { + return; + } + var slot = geometry.controlify$getHoveredSlot(); + if (slot != previousSlot) { + context.playNavigationHaptic(controller); + previousSlot = slot; + } + var settings = ((ControllerImpl) controller).settings().generic.guide; + if (!settings.showScreenGuides) { + return; + } + var mouse = context.virtualMouse(); + guide.update(new ContainerContext( + Minecraft.getInstance().player, slot, screen.getMenu().getCarried(), + geometry.controlify$invokeHasClickedOutside(mouse.getCurrentX(1f), mouse.getCurrentY(1f), + geometry.controlify$getLeftPos(), geometry.controlify$getTopPos()), + (ControllerImpl) controller, settings.verbosity + )); + guideRenderer.setGuiScale(settings.screenGuiScale); + guideRenderer.extractRenderState(graphics, (int) mouse.getCurrentX(tickDelta), (int) mouse.getCurrentY(tickDelta), tickDelta); + } + + @Override + public void collectSnapPoints(Consumer output, Runnable next) { + for (var slot : screen.getMenu().slots) { + if (slot.isActive()) { + output.accept(new SnapPoint(geometry.controlify$getLeftPos() + slot.x + 8, + geometry.controlify$getTopPos() + slot.y + 8, 17)); + } + } + next.run(); + } + + public interface Access { + void controlify$clickSlot(@Nullable Slot slot, int slotId, int button, ContainerInput input); + + boolean controlify$handleItemSlotActions(ControllerImpl controller); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/CreateWorldScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/CreateWorldScreenBehaviour.java new file mode 100644 index 000000000..32872d0b2 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/CreateWorldScreenBehaviour.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; + +public final class CreateWorldScreenBehaviour implements ScreenBehaviour { + private final Access access; + private final ScreenContext context; + + public CreateWorldScreenBehaviour(ScreenContext context) { + this.context = context; + this.access = (Access) context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + context.playClickSound(); + access.controlify$createWorld(); + return; + } + next.run(); + } + + public interface Access { + void controlify$createWorld(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/CreativeModeInventoryScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/CreativeModeInventoryScreenBehaviour.java new file mode 100644 index 000000000..fc9fbfcd9 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/CreativeModeInventoryScreenBehaviour.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.platform.client.CreativeTabHelper; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; +import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; +import net.minecraft.world.item.CreativeModeTab; + +import java.util.List; + +public final class CreativeModeInventoryScreenBehaviour implements ScreenBehaviour { + private final CreativeTabHelper tabHelper; + + private final ScreenContext context; + + public CreativeModeInventoryScreenBehaviour(ScreenContext context) { + this.context = context; + this.tabHelper = PlatformClientUtil.createCreativeTabHelper(context.screen()); + } + + @Override + public void onWidgetRebuild(Runnable next) { + next.run(); + context.virtualMouse().snapToClosestPoint(); + } + + @SuppressWarnings("UnreachableCode") + @Override + public void handleNavigation(Controller controller, Runnable next) { + if (!context.isVirtualMouseEnabled()) { + next.run(); + return; + } + List tabs = tabHelper.getTabsForPage(tabHelper.getCurrentPage()); + if (tabs.isEmpty()) { + next.run(); + return; + } + while (ControlifyActions.GUI_NEXT_TAB.on(controller).pulse().consumePulse()) { + int newIndex = tabs.indexOf(tabHelper.getSelectedTab()) + 1; + if (newIndex >= tabs.size()) { + newIndex = 0; + + int newPage = tabHelper.getCurrentPage() + 1; + if (newPage >= tabHelper.getPageCount()) { + newPage = 0; + } + + tabHelper.setCurrentPage(newPage); + tabs = tabHelper.getTabsForPage(newPage); + } + + tabHelper.setSelectedTab(tabs.get(newIndex)); + } + while (ControlifyActions.GUI_PREV_TAB.on(controller).pulse().consumePulse()) { + int newIndex = tabs.indexOf(tabHelper.getSelectedTab()) - 1; + if (newIndex < 0) { + int newPage = tabHelper.getCurrentPage() - 1; + if (newPage < 0) { + newPage = tabHelper.getPageCount() - 1; + } + + tabHelper.setCurrentPage(newPage); + tabs = tabHelper.getTabsForPage(newPage); + newIndex = tabs.size() - 1; + } + + tabHelper.setSelectedTab(tabs.get(newIndex)); + } + + next.run(); + } + + @Override + public void collectSnapPoints(java.util.function.Consumer output, Runnable next) { + ((Access) context.screen()).controlify$collectTabSnapPoints(tabHelper.getTabsForPage(tabHelper.getCurrentPage()), output); + next.run(); + } + + public interface Access { + void controlify$collectTabSnapPoints(List tabs, java.util.function.Consumer output); + } + +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/DoneButtonGuideBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/DoneButtonGuideBehaviour.java new file mode 100644 index 000000000..5de9d6b24 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/DoneButtonGuideBehaviour.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.ScreenBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.action.ControlifyActions; +import net.minecraft.client.gui.components.Button; +import org.jspecify.annotations.Nullable; + +public final class DoneButtonGuideBehaviour implements ScreenBehaviour { + private final Access access; + private final ScreenContext context; + + public DoneButtonGuideBehaviour(ScreenContext context) { + this.context = context; + this.access = (Access) context.screen(); + } + + @Override + public void onWidgetRebuild(Runnable next) { + var done = access.controlify$getDoneButton(); + if (done != null) { + context.buttonGuide().addGuideToButton(done, ControlifyActions.GUI_BACK, ButtonGuidePredicate.always()); + } + next.run(); + } + + public interface Access { + @Nullable Button controlify$getDoneButton(); + } +} diff --git a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/ItemSlotControllerAction.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ItemSlotControllerAction.java similarity index 70% rename from src/main/java/dev/isxander/controlify/screenop/compat/vanilla/ItemSlotControllerAction.java rename to src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ItemSlotControllerAction.java index c08d1ab2e..ae5c50118 100644 --- a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/ItemSlotControllerAction.java +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ItemSlotControllerAction.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.screenop.compat.vanilla; +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.impl.controller.ControllerImpl; import net.minecraft.client.gui.ItemSlotMouseAction; import net.minecraft.world.item.ItemStack; @@ -14,6 +14,6 @@ public interface ItemSlotControllerAction extends ItemSlotMouseAction { boolean controlify$onControllerInput( ItemStack stack, int hoveredSlotIndex, - ControllerEntity controller + ControllerImpl controller ); } diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/JoinMultiplayerScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/JoinMultiplayerScreenBehaviour.java new file mode 100644 index 000000000..469f32b24 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/JoinMultiplayerScreenBehaviour.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; +import net.minecraft.client.gui.screens.multiplayer.ServerSelectionList; +import org.jspecify.annotations.Nullable; + +public final class JoinMultiplayerScreenBehaviour implements ScreenBehaviour { + private final Access access; + private final ScreenContext context; + private final JoinMultiplayerScreen screen; + + public JoinMultiplayerScreenBehaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + this.access = (Access) context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (screen.getFocused() != null && screen.getFocused() instanceof Button) { + if (ControlifyActions.GUI_BACK.on(controller).pulse().consumeAllPulses()) { + screen.setFocused(access.controlify$getServerList()); + return; + } + } + + if (ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + Button directConnectButton = access.controlify$getDirectConnectButton(); + if (directConnectButton != null) { + context.playClickSound(); + directConnectButton.onPress(new net.minecraft.client.input.KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN, 0, 0)); + return; + } + } + + if (ControlifyActions.GUI_ABSTRACT_ACTION_2.on(controller).pulse().consumeAllPulses()) { + Button addServerButton = access.controlify$getAddServerButton(); + if (addServerButton != null) { + context.playClickSound(); + addServerButton.onPress(new net.minecraft.client.input.KeyEvent(com.mojang.blaze3d.platform.InputConstants.KEY_RETURN, 0, 0)); + return; + } + } + next.run(); + } + + @Override + public void onWidgetRebuild(Runnable next) { + Button backButton = access.controlify$getBackButton(); + Button directConnectButton = access.controlify$getDirectConnectButton(); + Button addServerButton = access.controlify$getAddServerButton(); + + if (backButton != null) { + context.buttonGuide().addGuideToButton( + backButton, + ControlifyActions.GUI_BACK, + ButtonGuidePredicate.always() + ); + } + + if (directConnectButton != null) { + context.buttonGuide().addGuideToButton( + directConnectButton, + ControlifyActions.GUI_ABSTRACT_ACTION_1, + ButtonGuidePredicate.always() + ); + } + + if (addServerButton != null) { + context.buttonGuide().addGuideToButton( + addServerButton, + ControlifyActions.GUI_ABSTRACT_ACTION_2, + ButtonGuidePredicate.always() + ); + } + next.run(); + } + + public interface Access { + ServerSelectionList controlify$getServerList(); + + @Nullable Button controlify$getBackButton(); + + @Nullable Button controlify$getDirectConnectButton(); + + @Nullable Button controlify$getAddServerButton(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/LanguageSelectionBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/LanguageSelectionBehaviour.java new file mode 100644 index 000000000..62c7c87ba --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/LanguageSelectionBehaviour.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.ElementBehaviour; +import dev.isxander.controlify.api.screenop.ScreenContext; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.mixins.feature.screenop.impl.outofgame.OptionsSubScreenAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.events.GuiEventListener; + +public final class LanguageSelectionBehaviour implements ElementBehaviour { + private final Access access; + + public LanguageSelectionBehaviour(GuiEventListener element) { + this.access = (Access) element; + } + + @Override + public void handleInteraction(ScreenContext screen, Controller controller, Runnable next) { + String code = this.access.controlify$getLanguageCode(); + if (!ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses()) { + next.run(); + return; + } + var minecraft = Minecraft.getInstance(); + var languages = minecraft.getLanguageManager(); + if (!code.equals(languages.getSelected())) { + languages.setSelected(code); + minecraft.options.languageCode = code; + minecraft.reloadResourcePacks(); + minecraft.options.save(); + } + screen.playClickSound(); + MinecraftUtil.setScreen(((OptionsSubScreenAccessor) screen.screen()).controlify$getLastScreen()); + } + + public interface Access { + String controlify$getLanguageCode(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/PauseScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/PauseScreenBehaviour.java new file mode 100644 index 000000000..26330da53 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/PauseScreenBehaviour.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.screenop.ScreenWidgets; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.mixins.feature.screenop.impl.outofgame.PauseScreenAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractButton; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.PauseScreen; +import net.minecraft.client.gui.screens.options.OptionsScreen; +import org.jetbrains.annotations.Nullable; + +public final class PauseScreenBehaviour implements ScreenBehaviour { + private final Access access; + private final ScreenContext context; + private final PauseScreen screen; + private final Minecraft minecraft = Minecraft.getInstance(); + + public PauseScreenBehaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + this.access = (Access) context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + + if (ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + MinecraftUtil.setScreen(new OptionsScreen(screen, minecraft.options/*? if <26.3 {*//*, true*//*?}*/)); + return; + } + if (ControlifyActions.GUI_ABSTRACT_ACTION_2.on(controller).pulse().consumeAllPulses()) { + ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses(); + screen.setFocused(access.controlify$getDisconnectButton()); + } + next.run(); + } + + @Override + public void onWidgetRebuild(Runnable next) { + + if (((PauseScreenAccessor) screen).getShowPauseMenu()) { + ScreenWidgets.find(screen, "menu.returnToGame").ifPresent(widget -> { + context.buttonGuide().addGuideToButton( + (AbstractButton) widget, + ControlifyActions.GUI_BACK, + ButtonGuidePredicate.always() + ); + }); + ScreenWidgets.find(screen, "menu.options").ifPresent(widget -> { + context.buttonGuide().addGuideToButton( + (AbstractButton) widget, + ControlifyActions.GUI_ABSTRACT_ACTION_1, + ButtonGuidePredicate.always() + ); + }); + + Button disconnectButton = access.controlify$getDisconnectButton(); + if (disconnectButton != null) { + context.buttonGuide().addGuideToButton( + disconnectButton, + () -> disconnectButton.isFocused() + ? ControlifyActions.GUI_PRESS + : ControlifyActions.GUI_ABSTRACT_ACTION_2, + ButtonGuidePredicate.always() + ); + } + } + next.run(); + } + + public interface Access { + @Nullable Button controlify$getDisconnectButton(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/RecipeBookScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/RecipeBookScreenBehaviour.java new file mode 100644 index 000000000..1b6def570 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/RecipeBookScreenBehaviour.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import com.mojang.blaze3d.platform.InputConstants; +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.mixins.feature.virtualmouse.snapping.RecipeBookComponentAccessor; +import dev.isxander.controlify.mixins.feature.virtualmouse.snapping.RecipeBookPageAccessor; +import net.minecraft.client.gui.components.ImageButton; +import net.minecraft.client.gui.screens.inventory.AbstractRecipeBookScreen; +import net.minecraft.client.gui.screens.recipebook.RecipeBookComponent; +import net.minecraft.client.gui.screens.recipebook.RecipeBookTabButton; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.client.input.MouseButtonInfo; + +import java.util.List; + +public final class RecipeBookScreenBehaviour implements ScreenBehaviour { + + private final RecipeBookScreenAccessor recipeBookScreenAccessor; + + private final ScreenContext> context; + + public RecipeBookScreenBehaviour(ScreenContext> context) { + this.context = context; + this.recipeBookScreenAccessor = (RecipeBookScreenAccessor) context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (!context.isVirtualMouseEnabled()) { + next.run(); + return; + } + + RecipeBookComponent recipeBookComponent = recipeBookScreenAccessor.controlify$getRecipeBookComponent(); + + if (!recipeBookComponent.isVisible()) { + next.run(); + return; + + } + for (var action : List.of(ControlifyActions.VMOUSE_PAGE_NEXT, ControlifyActions.VMOUSE_PAGE_PREV, + ControlifyActions.VMOUSE_PAGE_DOWN, ControlifyActions.VMOUSE_PAGE_UP)) { + while (action.on(controller).pulse().consumePulse()) { + RecipeBookComponentAccessor componentAccessor = (RecipeBookComponentAccessor) recipeBookComponent; + RecipeBookPageAccessor pageAccessor = (RecipeBookPageAccessor) componentAccessor.controlify$getRecipeBookPage(); + List tabs = componentAccessor.controlify$getTabButtons() + .stream().filter(tab -> tab.visible).toList(); + int index = tabs.indexOf(componentAccessor.controlify$getSelectedTab()); + ImageButton button = null; + if (action == ControlifyActions.VMOUSE_PAGE_NEXT) { + button = pageAccessor.controlify$getForwardButton(); + } else if (action == ControlifyActions.VMOUSE_PAGE_PREV) { + button = pageAccessor.controlify$getBackButton(); + } else if (action == ControlifyActions.VMOUSE_PAGE_DOWN && index >= 0 && index + 1 < tabs.size()) { + button = tabs.get(index + 1); + } else if (action == ControlifyActions.VMOUSE_PAGE_UP && index > 0) { + button = tabs.get(index - 1); + } + if (button != null) { + recipeBookComponent.mouseClicked(new MouseButtonEvent( + button.getX(), button.getY(), + new MouseButtonInfo(InputConstants.MOUSE_BUTTON_LEFT, 0)), false); + } + } + } + next.run(); + } + + @Override + public void collectSnapPoints(java.util.function.Consumer output, Runnable next) { + dev.isxander.controlify.impl.virtualmouse.SnapUtils.addRecipeSnapPoints( + recipeBookScreenAccessor.controlify$getRecipeBookComponent(), output); + next.run(); + } + + public interface RecipeBookScreenAccessor { + RecipeBookComponent controlify$getRecipeBookComponent(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/SelectWorldScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/SelectWorldScreenBehaviour.java new file mode 100644 index 000000000..a38f8ef34 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/SelectWorldScreenBehaviour.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.mixins.feature.screenop.impl.outofgame.SelectWorldScreenAccessor; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; +import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; + +public final class SelectWorldScreenBehaviour implements ScreenBehaviour { + private final ScreenContext context; + private final SelectWorldScreen screen; + + public SelectWorldScreenBehaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + context.playClickSound(); + var minecraft = Minecraft.getInstance(); + CreateWorldScreen.openFresh(minecraft, () -> MinecraftUtil.setScreen(screen)); + return; + } + + if (screen.getFocused() != null && screen.getFocused() instanceof Button) { + if (ControlifyActions.GUI_BACK.on(controller).pulse().consumeAllPulses()) { + screen.setFocused(((SelectWorldScreenAccessor) screen).controlify$getList()); + return; + } + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ServerAddressBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ServerAddressBehaviour.java new file mode 100644 index 000000000..5b2b9fc97 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ServerAddressBehaviour.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; + +public final class ServerAddressBehaviour implements ScreenBehaviour { + private final ServerAddressScreenAccess access; + private final ScreenContext context; + private final Screen screen; + + public ServerAddressBehaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + this.access = (ServerAddressScreenAccess) context.screen(); + } + + @Override + public void onWidgetRebuild(Runnable next) { + + Button doneButton = access.controlify$getDoneButton(); + Button backButton = access.controlify$getBackButton(); + + if (doneButton != null) { + context.buttonGuide().addGuideToButton( + doneButton, + () -> doneButton.isFocused() + ? ControlifyActions.GUI_PRESS + : ControlifyActions.GUI_ABSTRACT_ACTION_1, + ButtonGuidePredicate.always() + ); + } + + if (backButton != null) { + context.buttonGuide().addGuideToButton( + backButton, + () -> ControlifyActions.GUI_BACK, + ButtonGuidePredicate.always() + ); + } + next.run(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + Button doneButton = access.controlify$getDoneButton(); + if (doneButton != null && !doneButton.isFocused()) { + ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses(); + context.playClickSound(); + screen.setFocused(doneButton); + } + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ServerAddressScreenAccess.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ServerAddressScreenAccess.java new file mode 100644 index 000000000..c0d8a5533 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/ServerAddressScreenAccess.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import org.jspecify.annotations.Nullable; + +public interface ServerAddressScreenAccess { + EditBox controlify$getAddressField(); + + @Nullable Button controlify$getDoneButton(); + + @Nullable Button controlify$getBackButton(); +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/SignEditingBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/SignEditingBehaviour.java new file mode 100644 index 000000000..43d2e95ff --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/SignEditingBehaviour.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.gui.keyboard.CommonKeyboardHints; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardWidget; +import dev.isxander.controlify.impl.screenop.ScreenWidgets; +import dev.isxander.controlify.impl.utils.LazyComponentDims; +import dev.isxander.controlify.impl.utils.PrecomputedComponentDims; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.AbstractButton; +import net.minecraft.client.gui.screens.inventory.AbstractSignEditScreen; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.world.level.block.entity.SignBlockEntity; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +public final class SignEditingBehaviour implements ScreenBehaviour { + private final Access access; + private final ScreenContext context; + private final AbstractSignEditScreen screen; + private final Minecraft minecraft = Minecraft.getInstance(); + + private static final Component signLineHint = Component.translatable("controlify.hint.sign_line_change", + ActionGlyphs.action(ControlifyActions.GUI_SECONDARY_NAVI_UP.actionId()), + ActionGlyphs.action(ControlifyActions.GUI_SECONDARY_NAVI_DOWN.actionId())); + + private @org.jetbrains.annotations.Nullable List> signLineHintLines; + + public SignEditingBehaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + this.access = (Access) context.screen(); + } + + @Override + public void handleInteraction(Controller input, Runnable next) { + var controller = (ControllerImpl) input; + + var config = controller.settings().generic; + + // move cursor down a line + while (ControlifyActions.GUI_SECONDARY_NAVI_DOWN.on(controller).pulse().consumePulse()) { + access.controlify$moveLine(1); + + if (config.keyboard.hintSignLine && config.guide.showScreenGuides) { + config.keyboard.hintSignLine = false; + Controlify.instance().config().saveSafely(); + } + + context.playFocusChangeSound(); + } + + // move cursor up a line + while (ControlifyActions.GUI_SECONDARY_NAVI_UP.on(controller).pulse().consumePulse()) { + access.controlify$moveLine(-1); + + if (config.keyboard.hintSignLine && config.guide.showScreenGuides) { + config.keyboard.hintSignLine = false; + Controlify.instance().config().saveSafely(); + } + + context.playFocusChangeSound(); + } + next.run(); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta, Controller input, Runnable next) { + var controller = (ControllerImpl) input; + var config = controller.settings().generic; + KeyboardWidget keyboardWidget = access.controlify$getKeyboard(); + if (keyboardWidget != null && config.guide.showScreenGuides) { + if (config.keyboard.hintCursor) { + LazyComponentDims hint = CommonKeyboardHints.TEXT_CURSOR; + + int x = keyboardWidget.getRight() - hint.getWidth() - 2; + int y = keyboardWidget.getY() - hint.getHeight(); + + graphics.text(minecraft.font, hint.getComponent(), x, y, 0xFFFFFFFF, true); + } + + if (config.keyboard.hintSignLine && this.signLineHintLines != null) { + int y = 4; + for (PrecomputedComponentDims line : this.signLineHintLines) { + int lineWidth = line.width(); + FormattedCharSequence lineText = line.component(); + + graphics.text(minecraft.font, lineText, this.screen.width - 1 - lineWidth, y, 0xFFFFFFFF, true); + + y += minecraft.font.lineHeight; + } + } + } + next.run(); + } + + @Override + public void onWidgetRebuild(Runnable next) { + + ScreenWidgets.find(screen, CommonComponents.GUI_DONE).ifPresent(doneButton -> + context.buttonGuide().addGuideToButton( + (AbstractButton) doneButton, + ControlifyActions.GUI_BACK, + ButtonGuidePredicate.always() + )); + + // compute hint wrapping + SignBlockEntity sign = access.controlify$getSign(); + int signRight = (screen.width / 2) + (sign.getMaxTextLineWidth() / 2); + int maxLineWidth = screen.width - signRight; + this.signLineHintLines = minecraft.font.split(signLineHint, maxLineWidth).stream() + .map(cs -> PrecomputedComponentDims.of(cs, minecraft.font)) + .toList(); + next.run(); + } + + public interface Access { + void controlify$moveLine(int direction); + + SignBlockEntity controlify$getSign(); + + @Nullable KeyboardWidget controlify$getKeyboard(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/TitleScreenBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/TitleScreenBehaviour.java new file mode 100644 index 000000000..70b4919d5 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/TitleScreenBehaviour.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.screenop.ScreenWidgets; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.components.AbstractButton; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.options.OptionsScreen; + +public final class TitleScreenBehaviour implements ScreenBehaviour { + private final ScreenContext context; + private final TitleScreen screen; + private final Minecraft minecraft = Minecraft.getInstance(); + + public TitleScreenBehaviour(ScreenContext context) { + this.context = context; + this.screen = context.screen(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (ControlifyActions.GUI_BACK.on(controller).pulse().consumeAllPulses()) { + ScreenWidgets.find(screen, "menu.quit").ifPresent(widget -> { + ControlifyActions.GUI_PRESS.on(controller).pulse().consumeAllPulses(); + screen.setFocused(widget); + context.playClickSound(); + }); + } + + if (ControlifyActions.GUI_ABSTRACT_ACTION_1.on(controller).pulse().consumeAllPulses()) { + if (ScreenWidgets.find(screen, "menu.options").isPresent()) { + MinecraftUtil.setScreen(new OptionsScreen(screen, minecraft.options/*? if <26.3 {*//*, false*//*?}*/)); + context.playClickSound(); + } + } + next.run(); + } + + @Override + public void onWidgetRebuild(Runnable next) { + + ScreenWidgets.find(screen, "menu.quit").ifPresent(widget -> { + var button = (AbstractButton) widget; + context.buttonGuide().addGuideToButton( + button, + () -> button.isFocused() ? ControlifyActions.GUI_PRESS : ControlifyActions.GUI_BACK, + ButtonGuidePredicate.always() + ); + }); + + ScreenWidgets.find(screen, "menu.options").ifPresent(widget -> { + var button = (AbstractButton) widget; + context.buttonGuide().addGuideToButton( + button, + ControlifyActions.GUI_ABSTRACT_ACTION_1, + ButtonGuidePredicate.always() + ); + }); + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/package-info.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/package-info.java new file mode 100644 index 000000000..70a535c95 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/vanilla/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.impl.screenop.behaviours.vanilla; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorClickBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorClickBehaviour.java new file mode 100644 index 000000000..8d32a0c3b --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorClickBehaviour.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.virtualmouse; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import dev.isxander.controlify.impl.screenop.VirtualMouseState; + +public final class CursorClickBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + private final VirtualMouseState mouse; + + public CursorClickBehaviour(ScreenContextImpl context) { + this.context = context; + this.mouse = context.virtualMouse(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + if (context.isCurrentScreen()) { + mouse.emulateClicks(controller); + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorModeBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorModeBehaviour.java new file mode 100644 index 000000000..a9e1ea4cd --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorModeBehaviour.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.virtualmouse; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import dev.isxander.controlify.impl.screenop.VirtualMouseState; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import net.minecraft.network.chat.Component; + +/// Keeps native cursor state in sync with activation, input mode and user toggles. +public final class CursorModeBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + private final VirtualMouseState mouse; + + public CursorModeBehaviour(ScreenContextImpl context) { + this.context = context; + this.mouse = context.virtualMouse(); + } + + @Override + public void onScreenActiveChanged(boolean active) { + if (active) { + mouse.activate(); + } else { + mouse.deactivate(); + } + } + + @Override + public void update(float delta, Runnable next) { + boolean wasEnabled = mouse.isEnabled(); + mouse.synchronizeMode(); + if (mouse.isEnabled()) { + mouse.updatePosition(delta); + } + if (wasEnabled != mouse.isEnabled()) { + Controlify.instance().hideMouse(context.inputMode().isController(), !mouse.isEnabled()); + } + next.run(); + } + + @Override + public void handleInteraction(Controller controller, Runnable next) { + mouse.releaseUnlatchedClicks(controller); + next.run(); + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + while (context.isCurrentScreen() && ControlifyActions.VMOUSE_TOGGLE.on(controller).pulse().consumePulse()) { + if (mouse.mode() != VirtualMouseMode.DEFAULT) { + MinecraftUtil.sendToast(Component.translatable("controlify.toast.vmouse_unavailable.title"), + Component.translatable("controlify.toast.vmouse_unavailable.description"), false); + continue; + } + var screens = Controlify.instance().config().getSettings().globalSettings().virtualMouseScreens; + var type = context.screen().getClass(); + boolean enabled = !screens.remove(type); + if (enabled) { + screens.add(type); + } + mouse.synchronizeMode(); + Controlify.instance().hideMouse(true, !mouse.isEnabled()); + String toast = enabled ? "controlify.toast.vmouse_enabled" : "controlify.toast.vmouse_disabled"; + MinecraftUtil.sendToast(Component.translatable(toast + ".title"), Component.translatable(toast + ".description"), false); + Controlify.instance().config().saveSafely(); + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorMovementBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorMovementBehaviour.java new file mode 100644 index 000000000..e6f383740 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorMovementBehaviour.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.virtualmouse; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import dev.isxander.controlify.impl.screenop.VirtualMouseState; +import dev.isxander.controlify.impl.utils.ControllerUtils; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.navigation.ScreenDirection; +import org.joml.Vector2d; + +public final class CursorMovementBehaviour implements ScreenBehaviour { + private final ScreenContextImpl context; + private final VirtualMouseState mouse; + + public CursorMovementBehaviour(ScreenContextImpl context) { + this.context = context; + this.mouse = context.virtualMouse(); + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + Vector2d impulse = ControllerUtils.applyEasingToLength( + ControlifyActions.VMOUSE_MOVE_RIGHT.on(controller).continuous().getContinuous() - ControlifyActions.VMOUSE_MOVE_LEFT.on(controller).continuous().getContinuous(), + ControlifyActions.VMOUSE_MOVE_DOWN.on(controller).continuous().getContinuous() - ControlifyActions.VMOUSE_MOVE_UP.on(controller).continuous().getContinuous(), + x -> Math.pow(x, 3)); + Vector2d previous = mouse.previousMoveImpulse(); + mouse.setMoveImpulse(impulse); + mouse.refreshSnapPoints(); + if (impulse.x == 0 && impulse.y == 0 && (previous.x != 0 || previous.y != 0)) { + mouse.snapToClosestPoint(); + } + var settings = ((ControllerImpl) controller).input().settings().sensitivity; + var window = Minecraft.getInstance().getWindow(); + double scale = settings.isLCE ? 10.0 * window.getScreenWidth() / window.getGuiScaledWidth() + : 20.0 * Math.max(window.getWidth(), window.getHeight()) / 800.0; + mouse.move(impulse.x * scale * settings.virtualMouseSensitivity, impulse.y * scale * settings.virtualMouseSensitivity); + for (ScreenDirection direction : ScreenDirection.values()) { + var action = switch (direction) { + case UP -> ControlifyActions.VMOUSE_SNAP_UP; + case DOWN -> ControlifyActions.VMOUSE_SNAP_DOWN; + case LEFT -> ControlifyActions.VMOUSE_SNAP_LEFT; + case RIGHT -> ControlifyActions.VMOUSE_SNAP_RIGHT; + }; + while (context.isCurrentScreen() && action.on(controller).pulse().consumePulse()) { + mouse.snapInDirection(direction); + } + } + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorRenderBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorRenderBehaviour.java new file mode 100644 index 000000000..f86547f4c --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorRenderBehaviour.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.virtualmouse; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.debug.DebugProperties; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import dev.isxander.controlify.impl.screenop.VirtualMouseState; +import dev.isxander.controlify.impl.utils.CUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; + +public final class CursorRenderBehaviour implements ScreenBehaviour { + private static final Identifier CURSOR_TEXTURE = CUtil.rl("textures/gui/virtual_mouse.png"); + + private final ScreenContextImpl context; + private final VirtualMouseState mouse; + + public CursorRenderBehaviour(ScreenContextImpl context) { + this.context = context; + this.mouse = context.virtualMouse(); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, float tickDelta, Controller controller, Runnable next) { + next.run(); + if (!context.isCurrentScreen() || !mouse.isEnabled()) { + return; + } + if (DebugProperties.DEBUG_SNAPPING) { + for (var snapPoint : mouse.snapPoints()) { + graphics.fill(snapPoint.position().x() - snapPoint.range(), snapPoint.position().y() - snapPoint.range(), snapPoint.position().x() + snapPoint.range(), snapPoint.position().y() + snapPoint.range(), 0x33FFFFFF); + graphics.fill(snapPoint.position().x() - 1, snapPoint.position().y() - 1, snapPoint.position().x() + 1, snapPoint.position().y() + 1, snapPoint.equals(mouse.lastSnappedPoint()) ? 0xFFFFFF00 : 0xFFFF0000); + } + } + + var scaledX = mouse.x() * (double) Minecraft.getInstance().getWindow().getGuiScaledWidth() / (double) Minecraft.getInstance().getWindow().getScreenWidth(); + var scaledY = mouse.y() * (double) Minecraft.getInstance().getWindow().getGuiScaledHeight() / (double) Minecraft.getInstance().getWindow().getScreenHeight(); + + var pose = graphics.pose().pushMatrix(); + pose.translate((float) scaledX, (float) scaledY); + graphics.nextStratum(); + pose.scale(0.5f, 0.5f); + + graphics.blit( + RenderPipelines.GUI_TEXTURED, + CURSOR_TEXTURE, + -16, -16, + 0, 0, + 32, 32, + 32, 32 + ); + + pose.popMatrix(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorScrollBehaviour.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorScrollBehaviour.java new file mode 100644 index 000000000..f7c834cf4 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/CursorScrollBehaviour.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.screenop.behaviours.virtualmouse; + +import dev.isxander.controlify.api.controller.Controller; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import dev.isxander.controlify.impl.screenop.VirtualMouseState; + +public final class CursorScrollBehaviour implements ScreenBehaviour { + private final VirtualMouseState mouse; + + public CursorScrollBehaviour(ScreenContextImpl context) { + this.mouse = context.virtualMouse(); + } + + @Override + public void handleNavigation(Controller controller, Runnable next) { + mouse.addScroll(ControlifyActions.VMOUSE_SCROLL_UP.on(controller).continuous().getContinuous() + - ControlifyActions.VMOUSE_SCROLL_DOWN.on(controller).continuous().getContinuous()); + next.run(); + } + + @Override + public void update(float delta, Runnable next) { + mouse.updateScroll(delta); + next.run(); + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/package-info.java b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/package-info.java new file mode 100644 index 000000000..42ac6dfc8 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/behaviours/virtualmouse/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.impl.screenop.behaviours.virtualmouse; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/impl/screenop/package-info.java b/src/main/java/dev/isxander/controlify/impl/screenop/package-info.java new file mode 100644 index 000000000..064a31311 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/screenop/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package dev.isxander.controlify.impl.screenop; + +import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/dev/isxander/controlify/server/ControlifyHandshake.java b/src/main/java/dev/isxander/controlify/impl/server/ControlifyHandshake.java similarity index 89% rename from src/main/java/dev/isxander/controlify/server/ControlifyHandshake.java rename to src/main/java/dev/isxander/controlify/impl/server/ControlifyHandshake.java index 950771ee9..ed0cd697b 100644 --- a/src/main/java/dev/isxander/controlify/server/ControlifyHandshake.java +++ b/src/main/java/dev/isxander/controlify/impl/server/ControlifyHandshake.java @@ -4,12 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server; +package dev.isxander.controlify.impl.server; import com.mojang.logging.LogUtils; -import dev.isxander.controlify.platform.client.PlatformClientUtil; -import dev.isxander.controlify.platform.main.PlatformMainUtil; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.ChatFormatting; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.chat.Component; diff --git a/src/main/java/dev/isxander/controlify/server/ControlifyServer.java b/src/main/java/dev/isxander/controlify/impl/server/ControlifyServer.java similarity index 91% rename from src/main/java/dev/isxander/controlify/server/ControlifyServer.java rename to src/main/java/dev/isxander/controlify/impl/server/ControlifyServer.java index a381e9f31..5b0b0f5d1 100644 --- a/src/main/java/dev/isxander/controlify/server/ControlifyServer.java +++ b/src/main/java/dev/isxander/controlify/impl/server/ControlifyServer.java @@ -4,12 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server; +package dev.isxander.controlify.impl.server; -import dev.isxander.controlify.platform.network.SidedNetworkApi; -import dev.isxander.controlify.platform.main.PlatformMainUtil; -import dev.isxander.controlify.server.packets.*; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.platform.network.SidedNetworkApi; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.server.packets.*; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.server.level.ServerPlayer; import org.jetbrains.annotations.VisibleForTesting; diff --git a/src/main/java/dev/isxander/controlify/server/ControlifyServerConfig.java b/src/main/java/dev/isxander/controlify/impl/server/ControlifyServerConfig.java similarity index 84% rename from src/main/java/dev/isxander/controlify/server/ControlifyServerConfig.java rename to src/main/java/dev/isxander/controlify/impl/server/ControlifyServerConfig.java index ce61501df..66fc2f412 100644 --- a/src/main/java/dev/isxander/controlify/server/ControlifyServerConfig.java +++ b/src/main/java/dev/isxander/controlify/impl/server/ControlifyServerConfig.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server; +package dev.isxander.controlify.impl.server; -import dev.isxander.controlify.platform.main.PlatformMainUtil; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import dev.isxander.controlify.impl.utils.CUtil; import dev.isxander.yacl3.config.v2.api.ConfigClassHandler; import dev.isxander.yacl3.config.v2.api.SerialEntry; import dev.isxander.yacl3.config.v2.api.serializer.GsonConfigSerializerBuilder; diff --git a/src/main/java/dev/isxander/controlify/server/ServerPolicies.java b/src/main/java/dev/isxander/controlify/impl/server/ServerPolicies.java similarity index 96% rename from src/main/java/dev/isxander/controlify/server/ServerPolicies.java rename to src/main/java/dev/isxander/controlify/impl/server/ServerPolicies.java index 0b9125bd0..40b20ccef 100644 --- a/src/main/java/dev/isxander/controlify/server/ServerPolicies.java +++ b/src/main/java/dev/isxander/controlify/impl/server/ServerPolicies.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server; +package dev.isxander.controlify.impl.server; import java.util.Arrays; import java.util.Map; diff --git a/src/main/java/dev/isxander/controlify/server/ServerPolicy.java b/src/main/java/dev/isxander/controlify/impl/server/ServerPolicy.java similarity index 88% rename from src/main/java/dev/isxander/controlify/server/ServerPolicy.java rename to src/main/java/dev/isxander/controlify/impl/server/ServerPolicy.java index bb7494013..8ece3cdae 100644 --- a/src/main/java/dev/isxander/controlify/server/ServerPolicy.java +++ b/src/main/java/dev/isxander/controlify/impl/server/ServerPolicy.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server; +package dev.isxander.controlify.impl.server; public enum ServerPolicy { ALLOWED, diff --git a/src/main/java/dev/isxander/controlify/server/VibrateCommand.java b/src/main/java/dev/isxander/controlify/impl/server/VibrateCommand.java similarity index 87% rename from src/main/java/dev/isxander/controlify/server/VibrateCommand.java rename to src/main/java/dev/isxander/controlify/impl/server/VibrateCommand.java index e723f5403..9be937abb 100644 --- a/src/main/java/dev/isxander/controlify/server/VibrateCommand.java +++ b/src/main/java/dev/isxander/controlify/impl/server/VibrateCommand.java @@ -4,25 +4,21 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server; +package dev.isxander.controlify.impl.server; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.FloatArgumentType; import com.mojang.brigadier.arguments.IntegerArgumentType; -import com.mojang.brigadier.suggestion.SuggestionProvider; -import dev.isxander.controlify.platform.network.SidedNetworkApi; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.server.packets.EntityVibrationPacket; -import dev.isxander.controlify.server.packets.OriginVibrationPacket; -import dev.isxander.controlify.server.packets.VibrationPacket; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.platform.network.SidedNetworkApi; +import dev.isxander.controlify.impl.controller.rumble.RumbleSource; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.server.packets.EntityVibrationPacket; +import dev.isxander.controlify.impl.server.packets.OriginVibrationPacket; +import dev.isxander.controlify.impl.server.packets.VibrationPacket; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; -import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.commands.arguments.EntityArgument; import net.minecraft.commands.arguments.coordinates.Vec3Argument; -import net.minecraft.commands.synchronization.SuggestionProviders; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; @@ -33,16 +29,6 @@ import java.util.Locale; public class VibrateCommand { - private static final SuggestionProvider SOURCES_SUGGESTION = SuggestionProviders.register( - CUtil.rl("vibration_sources"), - (context, builder) -> SharedSuggestionProvider.suggestResource( - RumbleSource.values().stream() - .map(RumbleSource::id) - .toList(), - builder - ) - ); - public static void register(CommandDispatcher dispatcher) { dispatcher.register( Commands.literal("vibratecontroller") diff --git a/src/main/java/dev/isxander/controlify/server/packets/EntityVibrationPacket.java b/src/main/java/dev/isxander/controlify/impl/server/packets/EntityVibrationPacket.java similarity index 70% rename from src/main/java/dev/isxander/controlify/server/packets/EntityVibrationPacket.java rename to src/main/java/dev/isxander/controlify/impl/server/packets/EntityVibrationPacket.java index 7e3316ed6..a779bf497 100644 --- a/src/main/java/dev/isxander/controlify/server/packets/EntityVibrationPacket.java +++ b/src/main/java/dev/isxander/controlify/impl/server/packets/EntityVibrationPacket.java @@ -4,14 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server.packets; +package dev.isxander.controlify.impl.server.packets; -import dev.isxander.controlify.rumble.ContinuousRumbleEffect; -import dev.isxander.controlify.rumble.RumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.Easings; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.controller.rumble.RumbleSource; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.Easings; import net.minecraft.client.Minecraft; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; @@ -34,16 +33,15 @@ public record EntityVibrationPacket(int entityId, float range, int duration, Rum buf.readFloat(), buf.readInt(), RumbleState.unpackFromInt(buf.readInt()), - RumbleSource.get(buf.readIdentifier()) + new RumbleSource(buf.readIdentifier()) ) ); public RumbleEffect createEffect() { Entity entity = Minecraft.getInstance().level.getEntity(entityId); - return ContinuousRumbleEffect.builder() - .constant(state) + return RumbleEffect.continuous() + .constant(state.strong(), state.weak()) .inWorld(entity::position, 0, 1, range, Easings.toFloat(Easings::easeInSine)) - .timeout(duration) - .build(); + .timeout(duration); } } diff --git a/src/main/java/dev/isxander/controlify/server/packets/OriginVibrationPacket.java b/src/main/java/dev/isxander/controlify/impl/server/packets/OriginVibrationPacket.java similarity index 70% rename from src/main/java/dev/isxander/controlify/server/packets/OriginVibrationPacket.java rename to src/main/java/dev/isxander/controlify/impl/server/packets/OriginVibrationPacket.java index 950c4f850..84827c2e5 100644 --- a/src/main/java/dev/isxander/controlify/server/packets/OriginVibrationPacket.java +++ b/src/main/java/dev/isxander/controlify/impl/server/packets/OriginVibrationPacket.java @@ -4,14 +4,13 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server.packets; +package dev.isxander.controlify.impl.server.packets; -import dev.isxander.controlify.rumble.ContinuousRumbleEffect; -import dev.isxander.controlify.rumble.RumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.utils.CUtil; -import dev.isxander.controlify.utils.Easings; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.controller.rumble.RumbleSource; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.Easings; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.Identifier; @@ -34,16 +33,15 @@ public record OriginVibrationPacket(Vector3f origin, float effectRange, int dura buf.readFloat(), buf.readVarInt(), RumbleState.unpackFromInt(buf.readInt()), - RumbleSource.get(buf.readIdentifier()) + new RumbleSource(buf.readIdentifier()) ) ); public RumbleEffect createEffect() { var originVec3 = new Vec3(origin); - return ContinuousRumbleEffect.builder() - .constant(state) + return RumbleEffect.continuous() + .constant(state.strong(), state.weak()) .inWorld(() -> originVec3, 0, 1, effectRange, Easings.toFloat(Easings::easeInSine)) - .timeout(duration) - .build(); + .timeout(duration); } } diff --git a/src/main/java/dev/isxander/controlify/server/packets/ServerPolicyPacket.java b/src/main/java/dev/isxander/controlify/impl/server/packets/ServerPolicyPacket.java similarity index 86% rename from src/main/java/dev/isxander/controlify/server/packets/ServerPolicyPacket.java rename to src/main/java/dev/isxander/controlify/impl/server/packets/ServerPolicyPacket.java index e96679ed9..f6d6ecc20 100644 --- a/src/main/java/dev/isxander/controlify/server/packets/ServerPolicyPacket.java +++ b/src/main/java/dev/isxander/controlify/impl/server/packets/ServerPolicyPacket.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server.packets; +package dev.isxander.controlify.impl.server.packets; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.resources.Identifier; diff --git a/src/main/java/dev/isxander/controlify/server/packets/VibrationPacket.java b/src/main/java/dev/isxander/controlify/impl/server/packets/VibrationPacket.java similarity index 67% rename from src/main/java/dev/isxander/controlify/server/packets/VibrationPacket.java rename to src/main/java/dev/isxander/controlify/impl/server/packets/VibrationPacket.java index 5c5708a44..ea3397697 100644 --- a/src/main/java/dev/isxander/controlify/server/packets/VibrationPacket.java +++ b/src/main/java/dev/isxander/controlify/impl/server/packets/VibrationPacket.java @@ -4,13 +4,12 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.server.packets; +package dev.isxander.controlify.impl.server.packets; -import dev.isxander.controlify.rumble.BasicRumbleEffect; -import dev.isxander.controlify.rumble.RumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.controller.rumble.RumbleSource; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.Minecraft; import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; @@ -28,7 +27,7 @@ public record VibrationPacket(RumbleSource source, RumbleState[] frames) { } }, buf -> { - RumbleSource source = RumbleSource.get(buf.readIdentifier()); + RumbleSource source = new RumbleSource(buf.readIdentifier()); RumbleState[] frames = new RumbleState[buf.readInt()]; for (int i = 0; i < frames.length; i++) { frames[i] = RumbleState.unpackFromInt(buf.readInt()); @@ -38,6 +37,6 @@ public record VibrationPacket(RumbleSource source, RumbleState[] frames) { ); public RumbleEffect createEffect() { - return new BasicRumbleEffect(frames).earlyFinish(() -> Minecraft.getInstance().level == null); + return RumbleEffect.finite().byTick(tick -> frames[tick], frames.length).stopCondition(() -> Minecraft.getInstance().level == null); } } diff --git a/src/main/java/dev/isxander/controlify/sound/ControlifyClientSounds.java b/src/main/java/dev/isxander/controlify/impl/sound/ControlifyClientSounds.java similarity index 89% rename from src/main/java/dev/isxander/controlify/sound/ControlifyClientSounds.java rename to src/main/java/dev/isxander/controlify/impl/sound/ControlifyClientSounds.java index add2833f1..f2d7ea12c 100644 --- a/src/main/java/dev/isxander/controlify/sound/ControlifyClientSounds.java +++ b/src/main/java/dev/isxander/controlify/impl/sound/ControlifyClientSounds.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.sound; +package dev.isxander.controlify.impl.sound; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.resources.Identifier; import net.minecraft.sounds.SoundEvent; diff --git a/src/main/java/dev/isxander/controlify/utils/CUtil.java b/src/main/java/dev/isxander/controlify/impl/utils/CUtil.java similarity index 92% rename from src/main/java/dev/isxander/controlify/utils/CUtil.java rename to src/main/java/dev/isxander/controlify/impl/utils/CUtil.java index 31ecbc478..2364a2ad0 100644 --- a/src/main/java/dev/isxander/controlify/utils/CUtil.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/CUtil.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import com.mojang.blaze3d.Blaze3D; -import dev.isxander.controlify.platform.client.PlatformClientUtil; -import dev.isxander.controlify.utils.log.ControlifyLogger; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.utils.log.ControlifyLogger; import net.minecraft.core.HolderSet; import net.minecraft.core.TypedInstance; import net.minecraft.util.Util; @@ -18,7 +18,6 @@ import org.apache.commons.codec.binary.Hex; import org.slf4j.LoggerFactory; -import java.io.IOException; import java.net.URI; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; @@ -39,8 +38,8 @@ public static void openUri(String uri) { //? if >=26.3 { Blaze3D.openUri(URI.create(uri)); //?} else { - /*Util.getPlatform().openUri(uri); - *///?} + //Util.getPlatform().openUri(uri); + //?} } public static Supplier lazyInit(Supplier supplier) { diff --git a/src/main/java/dev/isxander/controlify/utils/ColorUtils.java b/src/main/java/dev/isxander/controlify/impl/utils/ColorUtils.java similarity index 91% rename from src/main/java/dev/isxander/controlify/utils/ColorUtils.java rename to src/main/java/dev/isxander/controlify/impl/utils/ColorUtils.java index a42520d28..e716ce941 100644 --- a/src/main/java/dev/isxander/controlify/utils/ColorUtils.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/ColorUtils.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import net.minecraft.util.Mth; diff --git a/src/main/java/dev/isxander/controlify/utils/ControllerUtils.java b/src/main/java/dev/isxander/controlify/impl/utils/ControllerUtils.java similarity index 80% rename from src/main/java/dev/isxander/controlify/utils/ControllerUtils.java rename to src/main/java/dev/isxander/controlify/impl/utils/ControllerUtils.java index a43df5f04..108840fb4 100644 --- a/src/main/java/dev/isxander/controlify/utils/ControllerUtils.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/ControllerUtils.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.touchpad.Touchpads; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.touchpad.Touchpads; import net.minecraft.CrashReport; import net.minecraft.CrashReportCategory; import net.minecraft.ReportedException; @@ -20,39 +20,28 @@ import java.util.stream.Stream; public class ControllerUtils { - public static String createControllerString(ControllerEntity controller) { + public static String createControllerString(ControllerImpl controller) { return String.format("'%s'#%s-%s (%s)", controller.name(), controller.info().ucid().toString(), - controller.info().hid().map(hid -> hid.hidid().toString()).orElse("hid"), - controller.info().type().friendlyName() + controller.info().hid().toString(), + controller.info().type().friendlyName().orElse(null) ); } - public static void wrapControllerError(Runnable runnable, String errorTitle, ControllerEntity controller) { + public static void wrapControllerError(Runnable runnable, String errorTitle, ControllerImpl controller) { try { runnable.run(); } catch (Throwable e) { CrashReport crashReport = CrashReport.forThrowable(e, errorTitle); CrashReportCategory category = crashReport.addCategory("Affected controller"); category.setDetail("Controller name", controller.name()); - category.setDetail("Controller identification", controller.driverName()); + category.setDetail("Controller identification", controller.driverReportedName()); category.setDetail("Controller type", controller.getClass().getCanonicalName()); throw new ReportedException(crashReport); } } - public static float deadzone(float value, float deadzone) { - return (value - Math.copySign(Math.min(deadzone, Math.abs(value)), value)) / (1 - deadzone); - } - - public static float applyCircularityX(float x, float y) { - return (float) (x * Math.sqrt(1 - (y * y) / 2)); - } - public static float applyCircularityY(float x, float y) { - return (float) (y * Math.sqrt(1 - (x * x) / 2)); - } - public static Vector2d applyEasingToLength(double x, double y, Function easing) { double length = Math.sqrt(x * x + y * y); if(length == 0.0) diff --git a/src/main/java/dev/isxander/controlify/utils/CursorUtils.java b/src/main/java/dev/isxander/controlify/impl/utils/CursorUtils.java similarity index 86% rename from src/main/java/dev/isxander/controlify/utils/CursorUtils.java rename to src/main/java/dev/isxander/controlify/impl/utils/CursorUtils.java index a99eddab9..a8584b876 100644 --- a/src/main/java/dev/isxander/controlify/utils/CursorUtils.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/CursorUtils.java @@ -4,15 +4,15 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import com.mojang.blaze3d.platform.Window; //? if >=26.3 { import org.lwjgl.sdl.SDLMouse; //?} else { -/*import org.lwjgl.glfw.GLFW; -*///?} +//import org.lwjgl.glfw.GLFW; +//?} public final class CursorUtils { @@ -42,7 +42,7 @@ public static void setPosition(Window window, float x, float y) { //? if >=26.3 { SDLMouse.SDL_WarpMouseInWindow(window.handle(), x, y); //?} else { - /*GLFW.glfwSetCursorPos(window.handle(), x, y); - *///?} + //GLFW.glfwSetCursorPos(window.handle(), x, y); + //?} } } diff --git a/src/main/java/dev/isxander/controlify/impl/utils/DebugDump.java b/src/main/java/dev/isxander/controlify/impl/utils/DebugDump.java new file mode 100644 index 000000000..09bf31e04 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/utils/DebugDump.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.utils; + +import com.sun.jna.Platform; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import dev.isxander.controlify.impl.controllermanager.ControllerBackend; +import dev.isxander.controlify.impl.controllermanager.ControllerCoordinator; +import dev.isxander.controlify.impl.controllermanager.ControllerManager; +import dev.isxander.controlify.impl.platform.Environment; +import dev.isxander.controlify.impl.platform.main.PlatformMainUtil; +import net.minecraft.SharedConstants; +import net.minecraft.resources.Identifier; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Optional; + +public class DebugDump { + public static String dumpDebug() { + IndentedStringBuilder dump = new IndentedStringBuilder(); + + LocalDateTime dateTime = LocalDateTime.now(); + String formattedDate = dateTime.format(DateTimeFormatter.ISO_DATE_TIME); + dump.line("CONTROLIFY DEBUG DUMP - ", formattedDate, '\n'); + + dump.line("Controlify version: ", PlatformMainUtil.getControlifyVersion()); + dump.line("Minecraft version: ", SharedConstants.getCurrentVersion().name()); + + if (PlatformMainUtil.getEnv() == Environment.CLIENT) { + dump.line("Client").pushIndent(); + dumpClientDebug(dump); + dump.popIndent(); + } + + return dump.build(); + } + + private static void dumpClientDebug(IndentedStringBuilder dump) { + dump.line("Platform: ", Platform.RESOURCE_PREFIX); + dump.line(); + + Optional controllerCoordinatorOpt = Controlify.instance().getControllerCoordinator(); + if (controllerCoordinatorOpt.isPresent()) { + ControllerCoordinator controllerCoordinator = controllerCoordinatorOpt.get(); + ControllerBackend backend = controllerCoordinator.backend(); + dump.line("Controller backend: ", backend != null ? backend.getClass().getName() : "null"); + + Optional controllerManagerOpt = Controlify.instance().getControllerManager(); + if (controllerManagerOpt.isPresent()) { + ControllerManager controllerManager = controllerManagerOpt.get(); + + dump.line("Connected Controllers:").pushIndent(); + for (ControllerImpl controller : controllerManager.getAllControllers()) { + dump.line("Name: ", controller.name()); + dump.line("Identified type: ", controller.info().type()); + dump.line("UID: ", controller.uid()); + dump.line("UCID: ", controller.info().ucid()); + dump.line("HID: ", controller.info().hid()); + + InputComponentImpl input = controller.input(); + dump.line("Input Component:").pushIndent(); + dump.line("Definitely gamepad: ", input.isGamepad()); + dump.line("Available inputs:").pushIndent(); + for (Identifier button : input.physicalButtons()) { + dump.line("BTN ", button); + } + for (Identifier axis : input.physicalAxes()) { + dump.line("AXS ", axis); + } + dump.popIndent(); // available inputs + + dump.popIndent(); // input component + + dump.line("Rumble supported: ", controller.rumble().isPresent()); + dump.line("Trigger rumble supported: ", controller.triggerRumble().isPresent()); + dump.line("Battery level: ", controller.batteryLevel().map(b -> b.getBatteryLevel().toString()).orElse("UNSUPPORTED")); + dump.line("Gyro supported: ", controller.gyro().isPresent()); + dump.line("Touchpads supported: ", controller.touchpad().map(touchpad -> touchpad.touchpads().length).orElse(0)); + dump.line("HD haptics supported: ", controller.hdHaptics().isPresent()); + dump.line("Log:").pushIndent().line(controller.logger().export()).popIndent(); + + dump.line(); + } + dump.popIndent(); // connected controllers + } + } + } + + private static class IndentedStringBuilder { + private final StringBuilder sb = new StringBuilder(); + private int indent; + + public IndentedStringBuilder line(Object... parts) { + sb.append(" ".repeat(indent)); + for (Object part : parts) { + String stringified = part.toString() + .replace("\n", "\n" + " ".repeat(indent)); + + sb.append(stringified); + } + sb.append('\n'); + + return this; + } + + public IndentedStringBuilder pushIndent() { + indent++; + return this; + } + + public IndentedStringBuilder popIndent() { + indent--; + return this; + } + + public String build() { + return sb.toString(); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/utils/DebugLog.java b/src/main/java/dev/isxander/controlify/impl/utils/DebugLog.java similarity index 74% rename from src/main/java/dev/isxander/controlify/utils/DebugLog.java rename to src/main/java/dev/isxander/controlify/impl/utils/DebugLog.java index 7d41b2c9f..ef29c9f36 100644 --- a/src/main/java/dev/isxander/controlify/utils/DebugLog.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/DebugLog.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; -import dev.isxander.controlify.debug.DebugProperties; +import dev.isxander.controlify.impl.debug.DebugProperties; public class DebugLog { public static void log(String message, Object... args) { diff --git a/src/main/java/dev/isxander/controlify/utils/DebugOverlayHelper.java b/src/main/java/dev/isxander/controlify/impl/utils/DebugOverlayHelper.java similarity index 92% rename from src/main/java/dev/isxander/controlify/utils/DebugOverlayHelper.java rename to src/main/java/dev/isxander/controlify/impl/utils/DebugOverlayHelper.java index bf5d3d95b..372f4778c 100644 --- a/src/main/java/dev/isxander/controlify/utils/DebugOverlayHelper.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/DebugOverlayHelper.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import net.minecraft.ChatFormatting; import net.minecraft.client.Minecraft; @@ -16,10 +16,6 @@ public final class DebugOverlayHelper { private static final Minecraft mc = Minecraft.getInstance(); - public static boolean isOverlayEnabled() { - return mc.debugEntries.isOverlayVisible(); - } - public static void toggleOverlay() { mc.debugEntries.toggleDebugOverlay(); } @@ -40,8 +36,8 @@ public static void reloadChunks() { //? if >=26.2 { mc.levelExtractor.allChanged(); //?} else { - /*mc.levelRenderer.allChanged(); - *///?} + //mc.levelRenderer.allChanged(); + //?} debugFeedbackTranslated("debug.reload_chunks.message"); } @@ -79,8 +75,8 @@ private static ChatComponent getChat() { //? if >=26.2 { return mc.gui.hud.getChat(); //?} else { - /*return mc.gui.getChat(); - *///?} + //return mc.gui.getChat(); + //?} } public static void clearChat() { diff --git a/src/main/java/dev/isxander/controlify/utils/DummyMixinTarget.java b/src/main/java/dev/isxander/controlify/impl/utils/DummyMixinTarget.java similarity index 88% rename from src/main/java/dev/isxander/controlify/utils/DummyMixinTarget.java rename to src/main/java/dev/isxander/controlify/impl/utils/DummyMixinTarget.java index 2dbe9d43b..ae80b6ffc 100644 --- a/src/main/java/dev/isxander/controlify/utils/DummyMixinTarget.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/DummyMixinTarget.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; /** * This class is used when you need stonecutter to comment out a mixin completely, diff --git a/src/main/java/dev/isxander/controlify/utils/Easings.java b/src/main/java/dev/isxander/controlify/impl/utils/Easings.java similarity index 94% rename from src/main/java/dev/isxander/controlify/utils/Easings.java rename to src/main/java/dev/isxander/controlify/impl/utils/Easings.java index 525e2fbef..07d958293 100644 --- a/src/main/java/dev/isxander/controlify/utils/Easings.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/Easings.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import net.minecraft.util.Mth; diff --git a/src/main/java/dev/isxander/controlify/impl/utils/HoldRepeatHelper.java b/src/main/java/dev/isxander/controlify/impl/utils/HoldRepeatHelper.java new file mode 100644 index 000000000..95eee4b5f --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/utils/HoldRepeatHelper.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.utils; + +/// Consumer-local repetition for shared actions whose general-purpose activator must not repeat. +/// Call once per fixed update, including while inactive. Delays are in fixed-update ticks. +/// Dedicated repeat actions should use the repeat-latch activator instead. +public final class HoldRepeatHelper { + private final int initialDelay; + private final int repeatDelay; + private int remainingDelay; + private boolean previouslyActive; + + public HoldRepeatHelper(int initialDelay, int repeatDelay) { + this.initialDelay = initialDelay; + this.repeatDelay = repeatDelay; + } + + public boolean shouldAction(boolean active) { + if (!active) { + this.reset(); + return false; + } + if (!this.previouslyActive) { + this.previouslyActive = true; + this.remainingDelay = this.initialDelay; + return true; + } + if (--this.remainingDelay <= 0) { + this.remainingDelay = this.repeatDelay; + return true; + } + return false; + } + + public void reset() { + this.previouslyActive = false; + this.remainingDelay = 0; + } +} diff --git a/src/main/java/dev/isxander/controlify/utils/InitialScreenRegistryDuck.java b/src/main/java/dev/isxander/controlify/impl/utils/InitialScreenRegistryDuck.java similarity index 85% rename from src/main/java/dev/isxander/controlify/utils/InitialScreenRegistryDuck.java rename to src/main/java/dev/isxander/controlify/impl/utils/InitialScreenRegistryDuck.java index 36a9b9987..668b0ae65 100644 --- a/src/main/java/dev/isxander/controlify/utils/InitialScreenRegistryDuck.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/InitialScreenRegistryDuck.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; @@ -18,8 +18,8 @@ static InitialScreenRegistryDuck get() { //? if >=26.2 { return (InitialScreenRegistryDuck) Minecraft.getInstance().gui; //?} else { - /*return (InitialScreenRegistryDuck) Minecraft.getInstance(); - *///?} + //return (InitialScreenRegistryDuck) Minecraft.getInstance(); + //?} } static void registerInitialScreen(Function screenFactory) { diff --git a/src/main/java/dev/isxander/controlify/utils/JsonTreeParser.java b/src/main/java/dev/isxander/controlify/impl/utils/JsonTreeParser.java similarity index 97% rename from src/main/java/dev/isxander/controlify/utils/JsonTreeParser.java rename to src/main/java/dev/isxander/controlify/impl/utils/JsonTreeParser.java index 17038c402..088279850 100644 --- a/src/main/java/dev/isxander/controlify/utils/JsonTreeParser.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/JsonTreeParser.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import com.google.gson.*; import org.quiltmc.parsers.json.JsonReader; diff --git a/src/main/java/dev/isxander/controlify/utils/LazyComponentDims.java b/src/main/java/dev/isxander/controlify/impl/utils/LazyComponentDims.java similarity index 79% rename from src/main/java/dev/isxander/controlify/utils/LazyComponentDims.java rename to src/main/java/dev/isxander/controlify/impl/utils/LazyComponentDims.java index afc7c3136..06342b2ec 100644 --- a/src/main/java/dev/isxander/controlify/utils/LazyComponentDims.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/LazyComponentDims.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.id.ControllerType; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controllermanager.ControllerType; +import dev.isxander.controlify.impl.font.ComponentFontHelper; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.network.chat.Component; @@ -49,7 +49,7 @@ public int getHeight() { int newCacheKey = getCacheKey(); if (this.height == -1 || this.cacheKey != newCacheKey) { this.cacheKey = newCacheKey; - this.height = BindingFontHelper.getComponentHeight(this.font, this.component); + this.height = ComponentFontHelper.getComponentHeight(this.font, this.component); } return this.height; } @@ -62,8 +62,8 @@ private int getCacheKey() { minecraft.getWindow().getGuiScaledHeight(), minecraft.font, Controlify.instance().getCurrentController() - .map(c -> c.info().type().namespace()) - .orElse(ControllerType.DEFAULT.namespace()) + .map(c -> c.info().type().id()) + .orElse(ControllerType.DEFAULT.id()) ); } } diff --git a/src/main/java/dev/isxander/controlify/utils/MinecraftUtil.java b/src/main/java/dev/isxander/controlify/impl/utils/MinecraftUtil.java similarity index 89% rename from src/main/java/dev/isxander/controlify/utils/MinecraftUtil.java rename to src/main/java/dev/isxander/controlify/impl/utils/MinecraftUtil.java index 992b75634..8e90ad7c0 100644 --- a/src/main/java/dev/isxander/controlify/utils/MinecraftUtil.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/MinecraftUtil.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import dev.isxander.controlify.mixins.feature.bind.GuiAccessor; import net.minecraft.client.Minecraft; @@ -26,16 +26,16 @@ public static void setScreen(@Nullable Screen screen) { //? if >=26.2 { minecraft.gui.setScreen(screen); //?} else { - /*minecraft.setScreen(screen); - *///?} + //minecraft.setScreen(screen); + //?} } public static void forceSetScreen(@Nullable Screen screen) { //? if >=26.2 { ((GuiAccessor) minecraft.gui).controlify$setScreenField(screen); //?} else { - /*minecraft.screen = screen; - *///?} + //minecraft.screen = screen; + //?} } @Contract(pure = true) @@ -43,8 +43,8 @@ public static void forceSetScreen(@Nullable Screen screen) { //? if >=26.2 { return minecraft.gui.screen(); //?} else { - /*return minecraft.screen; - *///?} + //return minecraft.screen; + //?} } @Contract(pure = true) @@ -52,8 +52,8 @@ public static void forceSetScreen(@Nullable Screen screen) { //? if >=26.2 { return minecraft.gui.overlay(); //?} else { - /*return minecraft.getOverlay(); - *///?} + //return minecraft.getOverlay(); + //?} } public static void sendToast(Component title, Component message, boolean longer) { diff --git a/src/main/java/dev/isxander/controlify/utils/MouseMinecraftCallNotifier.java b/src/main/java/dev/isxander/controlify/impl/utils/MouseMinecraftCallNotifier.java similarity index 83% rename from src/main/java/dev/isxander/controlify/utils/MouseMinecraftCallNotifier.java rename to src/main/java/dev/isxander/controlify/impl/utils/MouseMinecraftCallNotifier.java index f0ec7e47e..50a8b79ff 100644 --- a/src/main/java/dev/isxander/controlify/utils/MouseMinecraftCallNotifier.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/MouseMinecraftCallNotifier.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; public interface MouseMinecraftCallNotifier { void controlify$imFromMinecraftSetScreen(); diff --git a/src/main/java/dev/isxander/controlify/utils/MthExt.java b/src/main/java/dev/isxander/controlify/impl/utils/MthExt.java similarity index 89% rename from src/main/java/dev/isxander/controlify/utils/MthExt.java rename to src/main/java/dev/isxander/controlify/impl/utils/MthExt.java index 794c51f22..f7eadd197 100644 --- a/src/main/java/dev/isxander/controlify/utils/MthExt.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/MthExt.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import net.minecraft.util.Mth; diff --git a/src/main/java/dev/isxander/controlify/utils/Platform.java b/src/main/java/dev/isxander/controlify/impl/utils/Platform.java similarity index 98% rename from src/main/java/dev/isxander/controlify/utils/Platform.java rename to src/main/java/dev/isxander/controlify/impl/utils/Platform.java index a1dda9278..4ab6534af 100644 --- a/src/main/java/dev/isxander/controlify/utils/Platform.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/Platform.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; public enum Platform { UNKNOWN, diff --git a/src/main/java/dev/isxander/controlify/utils/PrecomputedComponentDims.java b/src/main/java/dev/isxander/controlify/impl/utils/PrecomputedComponentDims.java similarity index 75% rename from src/main/java/dev/isxander/controlify/utils/PrecomputedComponentDims.java rename to src/main/java/dev/isxander/controlify/impl/utils/PrecomputedComponentDims.java index 535bab134..347bbf5b5 100644 --- a/src/main/java/dev/isxander/controlify/utils/PrecomputedComponentDims.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/PrecomputedComponentDims.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.impl.font.ComponentFontHelper; import net.minecraft.client.gui.Font; import net.minecraft.network.chat.Component; import net.minecraft.util.FormattedCharSequence; @@ -14,13 +14,13 @@ public record PrecomputedComponentDims(T component, int width, int height) { public static PrecomputedComponentDims of(Component component, Font font) { int width = font.width(component); - int height = BindingFontHelper.getComponentHeight(font, component); + int height = ComponentFontHelper.getComponentHeight(font, component); return new PrecomputedComponentDims<>(component, width, height); } public static PrecomputedComponentDims of(FormattedCharSequence component, Font font) { int width = font.width(component); - int height = BindingFontHelper.getComponentHeight(font, component); + int height = ComponentFontHelper.getComponentHeight(font, component); return new PrecomputedComponentDims<>(component, width, height); } } diff --git a/src/main/java/dev/isxander/controlify/utils/ControllerSetupWizard.java b/src/main/java/dev/isxander/controlify/impl/utils/SetupWizard.java similarity index 94% rename from src/main/java/dev/isxander/controlify/utils/ControllerSetupWizard.java rename to src/main/java/dev/isxander/controlify/impl/utils/SetupWizard.java index 66c592ea1..4cf79c7b1 100644 --- a/src/main/java/dev/isxander/controlify/utils/ControllerSetupWizard.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/SetupWizard.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils; +package dev.isxander.controlify.impl.utils; import net.minecraft.client.gui.screens.Screen; @@ -14,7 +14,7 @@ /** * Collection of screens that are in a specific order to set up various aspects of a controller. */ -public class ControllerSetupWizard { +public class SetupWizard { private final Deque stages = new ArrayDeque<>(); private final List screens = new ArrayList<>(); diff --git a/src/main/java/dev/isxander/controlify/impl/utils/SetupWizardManager.java b/src/main/java/dev/isxander/controlify/impl/utils/SetupWizardManager.java new file mode 100644 index 000000000..7f418137a --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/utils/SetupWizardManager.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.utils; + +import dev.isxander.controlify.impl.gui.screen.DontInterruptScreen; + +import java.util.ArrayDeque; +import java.util.Queue; + +public class SetupWizardManager { + private final Queue setupWizards = new ArrayDeque<>(); + private SetupWizard currentSetupWizard = null; + + public void push(SetupWizard setupWizard) { + this.setupWizards.add(setupWizard); + } + + public void tick() { + if (MinecraftUtil.getOverlay() == null) { + if (this.currentSetupWizard != null && this.currentSetupWizard.isDone()) { + this.currentSetupWizard = null; + } + + if (!this.setupWizards.isEmpty() && !(MinecraftUtil.getScreen() instanceof DontInterruptScreen)) { + this.currentSetupWizard = this.setupWizards.poll(); + MinecraftUtil.setScreen(this.currentSetupWizard.start(MinecraftUtil.getScreen())); + } + } + } +} diff --git a/src/main/java/dev/isxander/controlify/utils/animation/api/Animatable.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/Animatable.java similarity index 95% rename from src/main/java/dev/isxander/controlify/utils/animation/api/Animatable.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/api/Animatable.java index cbc0d05c2..ad6efa79e 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/api/Animatable.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/Animatable.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.api; +package dev.isxander.controlify.impl.utils.animation.api; import org.jetbrains.annotations.ApiStatus; diff --git a/src/main/java/dev/isxander/controlify/utils/animation/api/Animation.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/Animation.java similarity index 96% rename from src/main/java/dev/isxander/controlify/utils/animation/api/Animation.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/api/Animation.java index 0f9b3b6f3..99a462502 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/api/Animation.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/Animation.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.api; +package dev.isxander.controlify.impl.utils.animation.api; -import dev.isxander.controlify.utils.animation.impl.AnimationImpl; +import dev.isxander.controlify.impl.utils.animation.impl.AnimationImpl; import java.util.function.Consumer; diff --git a/src/main/java/dev/isxander/controlify/utils/animation/api/AnimationGroup.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/AnimationGroup.java similarity index 90% rename from src/main/java/dev/isxander/controlify/utils/animation/api/AnimationGroup.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/api/AnimationGroup.java index cfdf32aa3..e7359188e 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/api/AnimationGroup.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/AnimationGroup.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.api; +package dev.isxander.controlify.impl.utils.animation.api; -import dev.isxander.controlify.utils.animation.impl.AnimationGroupImpl; +import dev.isxander.controlify.impl.utils.animation.impl.AnimationGroupImpl; /** * A group of {@link Animatable}s where all are played concurrently diff --git a/src/main/java/dev/isxander/controlify/utils/animation/api/AnimationSequence.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/AnimationSequence.java similarity index 90% rename from src/main/java/dev/isxander/controlify/utils/animation/api/AnimationSequence.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/api/AnimationSequence.java index db303d8f3..4b8ffec6b 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/api/AnimationSequence.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/AnimationSequence.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.api; +package dev.isxander.controlify.impl.utils.animation.api; -import dev.isxander.controlify.utils.animation.impl.AnimationSequenceImpl; +import dev.isxander.controlify.impl.utils.animation.impl.AnimationSequenceImpl; /** * A sequence of {@link Animatable}s where each animatable is played one diff --git a/src/main/java/dev/isxander/controlify/utils/animation/api/EasingFunction.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/EasingFunction.java similarity index 98% rename from src/main/java/dev/isxander/controlify/utils/animation/api/EasingFunction.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/api/EasingFunction.java index 410f14e4d..6ced261d3 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/api/EasingFunction.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/api/EasingFunction.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.api; +package dev.isxander.controlify.impl.utils.animation.api; import net.minecraft.util.Mth; diff --git a/src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationGroupImpl.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationGroupImpl.java similarity index 90% rename from src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationGroupImpl.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationGroupImpl.java index 41f96bc0a..3ce55b795 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationGroupImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationGroupImpl.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.impl; +package dev.isxander.controlify.impl.utils.animation.impl; -import dev.isxander.controlify.utils.animation.api.Animatable; -import dev.isxander.controlify.utils.animation.api.AnimationGroup; +import dev.isxander.controlify.impl.utils.animation.api.Animatable; +import dev.isxander.controlify.impl.utils.animation.api.AnimationGroup; import it.unimi.dsi.fastutil.objects.ObjectArraySet; import org.apache.commons.lang3.Validate; diff --git a/src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationImpl.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationImpl.java similarity index 95% rename from src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationImpl.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationImpl.java index eb9019fff..3d31c0e55 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationImpl.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.impl; +package dev.isxander.controlify.impl.utils.animation.impl; -import dev.isxander.controlify.utils.animation.api.Animation; -import dev.isxander.controlify.utils.animation.api.EasingFunction; +import dev.isxander.controlify.impl.utils.animation.api.Animation; +import dev.isxander.controlify.impl.utils.animation.api.EasingFunction; import it.unimi.dsi.fastutil.objects.ObjectArrayList; import java.util.List; diff --git a/src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationSequenceImpl.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationSequenceImpl.java similarity index 90% rename from src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationSequenceImpl.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationSequenceImpl.java index 90a94a2a3..63eef4fae 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/impl/AnimationSequenceImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/impl/AnimationSequenceImpl.java @@ -4,10 +4,10 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.impl; +package dev.isxander.controlify.impl.utils.animation.impl; -import dev.isxander.controlify.utils.animation.api.Animatable; -import dev.isxander.controlify.utils.animation.api.AnimationSequence; +import dev.isxander.controlify.impl.utils.animation.api.Animatable; +import dev.isxander.controlify.impl.utils.animation.api.AnimationSequence; import org.apache.commons.lang3.Validate; import java.util.ArrayDeque; diff --git a/src/main/java/dev/isxander/controlify/utils/animation/impl/Animator.java b/src/main/java/dev/isxander/controlify/impl/utils/animation/impl/Animator.java similarity index 83% rename from src/main/java/dev/isxander/controlify/utils/animation/impl/Animator.java rename to src/main/java/dev/isxander/controlify/impl/utils/animation/impl/Animator.java index cd1fe8f3c..9352cdfc3 100644 --- a/src/main/java/dev/isxander/controlify/utils/animation/impl/Animator.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/animation/impl/Animator.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.animation.impl; +package dev.isxander.controlify.impl.utils.animation.impl; -import dev.isxander.controlify.utils.animation.api.Animatable; +import dev.isxander.controlify.impl.utils.animation.api.Animatable; import it.unimi.dsi.fastutil.objects.ObjectArrayList; import java.util.List; diff --git a/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java b/src/main/java/dev/isxander/controlify/impl/utils/codec/CExtraCodecs.java similarity index 89% rename from src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java rename to src/main/java/dev/isxander/controlify/impl/utils/codec/CExtraCodecs.java index 1dc3e2128..73628ac65 100644 --- a/src/main/java/dev/isxander/controlify/utils/codec/CExtraCodecs.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/codec/CExtraCodecs.java @@ -4,14 +4,16 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.codec; +package dev.isxander.controlify.impl.utils.codec; import com.mojang.datafixers.util.Pair; +import com.mojang.datafixers.util.Unit; import com.mojang.serialization.*; import it.unimi.dsi.fastutil.bytes.ByteArrayList; import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.BiFunction; @@ -19,15 +21,24 @@ import java.util.stream.Stream; public final class CExtraCodecs { + public static final Codec EMPTY_MAP = Codec.unboundedMap(Codec.STRING, Codec.PASSTHROUGH).comapFlatMap( + map -> map.isEmpty() + ? DataResult.success(Unit.INSTANCE) + : DataResult.error(() -> "Expected an empty input map"), + unit -> Map.of() + ); + public static Codec fuzzy( List> codecs, - Function> encoderGetter) { + Function> encoderGetter + ) { return new FuzzyCodec<>(codecs, encoderGetter); } public static MapCodec fuzzyMap( List> codecs, - Function> encoderGetter) { + Function> encoderGetter + ) { return new FuzzyMapCodec<>(codecs, encoderGetter); } diff --git a/src/main/java/dev/isxander/controlify/utils/codec/FuzzyCodec.java b/src/main/java/dev/isxander/controlify/impl/utils/codec/FuzzyCodec.java similarity index 96% rename from src/main/java/dev/isxander/controlify/utils/codec/FuzzyCodec.java rename to src/main/java/dev/isxander/controlify/impl/utils/codec/FuzzyCodec.java index 110e3e7b2..39ec15a77 100644 --- a/src/main/java/dev/isxander/controlify/utils/codec/FuzzyCodec.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/codec/FuzzyCodec.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.codec; +package dev.isxander.controlify.impl.utils.codec; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.Codec; diff --git a/src/main/java/dev/isxander/controlify/utils/codec/FuzzyMapCodec.java b/src/main/java/dev/isxander/controlify/impl/utils/codec/FuzzyMapCodec.java similarity index 96% rename from src/main/java/dev/isxander/controlify/utils/codec/FuzzyMapCodec.java rename to src/main/java/dev/isxander/controlify/impl/utils/codec/FuzzyMapCodec.java index 1c49aceda..c31de7149 100644 --- a/src/main/java/dev/isxander/controlify/utils/codec/FuzzyMapCodec.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/codec/FuzzyMapCodec.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.codec; +package dev.isxander.controlify.impl.utils.codec; import com.mojang.serialization.*; diff --git a/src/main/java/dev/isxander/controlify/utils/codec/SetCodec.java b/src/main/java/dev/isxander/controlify/impl/utils/codec/SetCodec.java similarity index 98% rename from src/main/java/dev/isxander/controlify/utils/codec/SetCodec.java rename to src/main/java/dev/isxander/controlify/impl/utils/codec/SetCodec.java index 54c09eb6a..7b4e1bdbe 100644 --- a/src/main/java/dev/isxander/controlify/utils/codec/SetCodec.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/codec/SetCodec.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.codec; +package dev.isxander.controlify.impl.utils.codec; import com.mojang.datafixers.util.Pair; import com.mojang.datafixers.util.Unit; diff --git a/src/main/java/dev/isxander/controlify/utils/codec/StrictEitherMapCodec.java b/src/main/java/dev/isxander/controlify/impl/utils/codec/StrictEitherMapCodec.java similarity index 95% rename from src/main/java/dev/isxander/controlify/utils/codec/StrictEitherMapCodec.java rename to src/main/java/dev/isxander/controlify/impl/utils/codec/StrictEitherMapCodec.java index f8181654d..284ae5050 100644 --- a/src/main/java/dev/isxander/controlify/utils/codec/StrictEitherMapCodec.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/codec/StrictEitherMapCodec.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.codec; +package dev.isxander.controlify.impl.utils.codec; import com.mojang.serialization.*; diff --git a/src/main/java/dev/isxander/controlify/impl/utils/event/EventBus.java b/src/main/java/dev/isxander/controlify/impl/utils/event/EventBus.java new file mode 100644 index 000000000..57e85aa02 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/utils/event/EventBus.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.utils.event; + +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.EventSource; +import org.jspecify.annotations.NonNull; + +import java.util.Objects; +import java.util.concurrent.CopyOnWriteArrayList; + +/// Synchronous subscription-order dispatch with a snapshot of listeners per emission. +/// Subscription changes apply to later emissions; listener exceptions propagate. +public class EventBus implements EventSource { + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + + /// Notifies subscribers without forwarding to another stage. + public void emit(T event) { + for (Registration registration : this.listeners) { + registration.listener.accept(event); + } + } + + @Override + public @NonNull Subscription subscribe(@NonNull EventSink listener) { + Registration registration = new Registration(Objects.requireNonNull(listener, "listener")); + this.listeners.add(registration); + return registration; + } + + private final class Registration implements Subscription { + private final EventSink listener; + + private Registration(EventSink listener) { + this.listener = listener; + } + + @Override + public void close() { + EventBus.this.listeners.remove(this); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/impl/utils/event/EventTap.java b/src/main/java/dev/isxander/controlify/impl/utils/event/EventTap.java new file mode 100644 index 000000000..ab78bce23 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/impl/utils/event/EventTap.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.impl.utils.event; + +import dev.isxander.controlify.api.utils.event.EventSink; +import dev.isxander.controlify.api.utils.event.EventSource; +import dev.isxander.controlify.api.utils.event.UnaryEventStage; +import org.jspecify.annotations.NonNull; + +/// Notifies listeners synchronously on the emitting thread before forwarding an event. +/// Each emission uses a snapshot of subscriptions; changes made during a callback +/// apply to subsequent emissions. Listener exceptions propagate to the caller. +public class EventTap implements EventSource, UnaryEventStage { + private final EventBus bus = new EventBus<>(); + + @Override + public void onEvent(@NonNull T event, @NonNull EventSink downstream) { + this.emit(event); + downstream.accept(event); + } + + public void emit(T event) { + this.bus.emit(event); + } + + @Override + public @NonNull Subscription subscribe(@NonNull EventSink listener) { + return this.bus.subscribe(listener); + } +} diff --git a/src/main/java/dev/isxander/controlify/utils/log/AbstractControlifyLogger.java b/src/main/java/dev/isxander/controlify/impl/utils/log/AbstractControlifyLogger.java similarity index 98% rename from src/main/java/dev/isxander/controlify/utils/log/AbstractControlifyLogger.java rename to src/main/java/dev/isxander/controlify/impl/utils/log/AbstractControlifyLogger.java index 6a5f7969b..a19fd3d57 100644 --- a/src/main/java/dev/isxander/controlify/utils/log/AbstractControlifyLogger.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/log/AbstractControlifyLogger.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.log; +package dev.isxander.controlify.impl.utils.log; import net.minecraft.CrashReport; import net.minecraft.ReportType; diff --git a/src/main/java/dev/isxander/controlify/utils/log/ControlifyLogger.java b/src/main/java/dev/isxander/controlify/impl/utils/log/ControlifyLogger.java similarity index 97% rename from src/main/java/dev/isxander/controlify/utils/log/ControlifyLogger.java rename to src/main/java/dev/isxander/controlify/impl/utils/log/ControlifyLogger.java index ebda7cb66..ca0401577 100644 --- a/src/main/java/dev/isxander/controlify/utils/log/ControlifyLogger.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/log/ControlifyLogger.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.log; +package dev.isxander.controlify.impl.utils.log; import net.minecraft.CrashReport; import org.jetbrains.annotations.Contract; diff --git a/src/main/java/dev/isxander/controlify/utils/log/ControlifyMasterLogger.java b/src/main/java/dev/isxander/controlify/impl/utils/log/ControlifyMasterLogger.java similarity index 91% rename from src/main/java/dev/isxander/controlify/utils/log/ControlifyMasterLogger.java rename to src/main/java/dev/isxander/controlify/impl/utils/log/ControlifyMasterLogger.java index 0928c23ea..721e9f8f8 100644 --- a/src/main/java/dev/isxander/controlify/utils/log/ControlifyMasterLogger.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/log/ControlifyMasterLogger.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.log; +package dev.isxander.controlify.impl.utils.log; -import dev.isxander.controlify.debug.DebugProperties; +import dev.isxander.controlify.impl.debug.DebugProperties; import org.jetbrains.annotations.Nullable; public class ControlifyMasterLogger extends AbstractControlifyLogger { diff --git a/src/main/java/dev/isxander/controlify/utils/log/ControlifySubLogger.java b/src/main/java/dev/isxander/controlify/impl/utils/log/ControlifySubLogger.java similarity index 81% rename from src/main/java/dev/isxander/controlify/utils/log/ControlifySubLogger.java rename to src/main/java/dev/isxander/controlify/impl/utils/log/ControlifySubLogger.java index f1228f8fe..3017a9bd2 100644 --- a/src/main/java/dev/isxander/controlify/utils/log/ControlifySubLogger.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/log/ControlifySubLogger.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.log; +package dev.isxander.controlify.impl.utils.log; public interface ControlifySubLogger extends ControlifyLogger { String name(); diff --git a/src/main/java/dev/isxander/controlify/utils/log/ControlifySubLoggerImpl.java b/src/main/java/dev/isxander/controlify/impl/utils/log/ControlifySubLoggerImpl.java similarity index 95% rename from src/main/java/dev/isxander/controlify/utils/log/ControlifySubLoggerImpl.java rename to src/main/java/dev/isxander/controlify/impl/utils/log/ControlifySubLoggerImpl.java index dc5d69c55..4c64becdb 100644 --- a/src/main/java/dev/isxander/controlify/utils/log/ControlifySubLoggerImpl.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/log/ControlifySubLoggerImpl.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.log; +package dev.isxander.controlify.impl.utils.log; import org.jetbrains.annotations.Nullable; diff --git a/src/main/java/dev/isxander/controlify/utils/log/LogLevel.java b/src/main/java/dev/isxander/controlify/impl/utils/log/LogLevel.java similarity index 78% rename from src/main/java/dev/isxander/controlify/utils/log/LogLevel.java rename to src/main/java/dev/isxander/controlify/impl/utils/log/LogLevel.java index 33b56d989..c09af6476 100644 --- a/src/main/java/dev/isxander/controlify/utils/log/LogLevel.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/log/LogLevel.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.log; +package dev.isxander.controlify.impl.utils.log; public enum LogLevel { INFO, diff --git a/src/main/java/dev/isxander/controlify/utils/log/LogMessage.java b/src/main/java/dev/isxander/controlify/impl/utils/log/LogMessage.java similarity index 96% rename from src/main/java/dev/isxander/controlify/utils/log/LogMessage.java rename to src/main/java/dev/isxander/controlify/impl/utils/log/LogMessage.java index f62d033d4..08ffff759 100644 --- a/src/main/java/dev/isxander/controlify/utils/log/LogMessage.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/log/LogMessage.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.log; +package dev.isxander.controlify.impl.utils.log; import java.io.PrintWriter; import java.io.StringWriter; diff --git a/src/main/java/dev/isxander/controlify/utils/predicates/ClientEntityPredicate.java b/src/main/java/dev/isxander/controlify/impl/utils/predicates/ClientEntityPredicate.java similarity index 97% rename from src/main/java/dev/isxander/controlify/utils/predicates/ClientEntityPredicate.java rename to src/main/java/dev/isxander/controlify/impl/utils/predicates/ClientEntityPredicate.java index 28318fb0c..2d5101c78 100644 --- a/src/main/java/dev/isxander/controlify/utils/predicates/ClientEntityPredicate.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/predicates/ClientEntityPredicate.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.predicates; +package dev.isxander.controlify.impl.utils.predicates; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; @@ -12,8 +12,8 @@ import net.minecraft.advancements.predicates.*; import net.minecraft.advancements.predicates.entity.*; //?} else { -/*import net.minecraft.advancements.criterion.*; -*///?} +//import net.minecraft.advancements.criterion.*; +//?} import net.minecraft.client.player.LocalPlayer; import net.minecraft.util.ExtraCodecs; import net.minecraft.world.entity.Entity; diff --git a/src/main/java/dev/isxander/controlify/utils/predicates/ClientPredicateUtils.java b/src/main/java/dev/isxander/controlify/impl/utils/predicates/ClientPredicateUtils.java similarity index 91% rename from src/main/java/dev/isxander/controlify/utils/predicates/ClientPredicateUtils.java rename to src/main/java/dev/isxander/controlify/impl/utils/predicates/ClientPredicateUtils.java index 6e48b0cd2..6bf1663cc 100644 --- a/src/main/java/dev/isxander/controlify/utils/predicates/ClientPredicateUtils.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/predicates/ClientPredicateUtils.java @@ -4,16 +4,16 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.predicates; +package dev.isxander.controlify.impl.utils.predicates; -import dev.isxander.controlify.utils.CUtil; +import dev.isxander.controlify.impl.utils.CUtil; import it.unimi.dsi.fastutil.ints.IntList; //? if >=26.2 { import net.minecraft.advancements.predicates.*; import net.minecraft.advancements.predicates.entity.*; //?} else { -/*import net.minecraft.advancements.criterion.*; -*///?} +//import net.minecraft.advancements.criterion.*; +//?} import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.SlotAccess; import net.minecraft.world.entity.SlotProvider; diff --git a/src/main/java/dev/isxander/controlify/utils/predicates/EntityEquipmentPredicate.java b/src/main/java/dev/isxander/controlify/impl/utils/predicates/EntityEquipmentPredicate.java similarity index 96% rename from src/main/java/dev/isxander/controlify/utils/predicates/EntityEquipmentPredicate.java rename to src/main/java/dev/isxander/controlify/impl/utils/predicates/EntityEquipmentPredicate.java index 971862f2b..235374fc3 100644 --- a/src/main/java/dev/isxander/controlify/utils/predicates/EntityEquipmentPredicate.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/predicates/EntityEquipmentPredicate.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.predicates; +package dev.isxander.controlify.impl.utils.predicates; import com.mojang.serialization.Codec; import com.mojang.serialization.codecs.RecordCodecBuilder; @@ -13,8 +13,8 @@ //? if >=26.2 { import net.minecraft.advancements.predicates.ItemPredicate; //?} else { -/*import net.minecraft.advancements.criterion.ItemPredicate; -*///?} +//import net.minecraft.advancements.criterion.ItemPredicate; +//?} import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EquipmentSlot; import net.minecraft.world.entity.LivingEntity; diff --git a/src/main/java/dev/isxander/controlify/utils/render/BaseRenderState.java b/src/main/java/dev/isxander/controlify/impl/utils/render/BaseRenderState.java similarity index 92% rename from src/main/java/dev/isxander/controlify/utils/render/BaseRenderState.java rename to src/main/java/dev/isxander/controlify/impl/utils/render/BaseRenderState.java index f61f4bc81..d6c2bd095 100644 --- a/src/main/java/dev/isxander/controlify/utils/render/BaseRenderState.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/render/BaseRenderState.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.render; +package dev.isxander.controlify.impl.utils.render; -import dev.isxander.controlify.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.navigation.ScreenRectangle; @@ -19,8 +19,8 @@ //? if >=26.3 { import com.mojang.renderpearl.api.pipeline.RenderPipeline; //?} else { -/*import com.mojang.blaze3d.pipeline.RenderPipeline; -*///?} +//import com.mojang.blaze3d.pipeline.RenderPipeline; +//?} public record BaseRenderState( RenderPipeline pipeline, diff --git a/src/main/java/dev/isxander/controlify/utils/render/CGuiElementRenderState.java b/src/main/java/dev/isxander/controlify/impl/utils/render/CGuiElementRenderState.java similarity index 86% rename from src/main/java/dev/isxander/controlify/utils/render/CGuiElementRenderState.java rename to src/main/java/dev/isxander/controlify/impl/utils/render/CGuiElementRenderState.java index f0a0de30b..64fbdda30 100644 --- a/src/main/java/dev/isxander/controlify/utils/render/CGuiElementRenderState.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/render/CGuiElementRenderState.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.render; +package dev.isxander.controlify.impl.utils.render; -import dev.isxander.controlify.platform.client.PlatformClientUtil; +import dev.isxander.controlify.impl.platform.client.PlatformClientUtil; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.renderer.state.gui.GuiElementRenderState; import net.minecraft.client.gui.render.TextureSetup; @@ -17,8 +17,8 @@ //? if >=26.3 { import com.mojang.renderpearl.api.pipeline.RenderPipeline; //?} else { -/*import com.mojang.blaze3d.pipeline.RenderPipeline; -*///?} +//import com.mojang.blaze3d.pipeline.RenderPipeline; +//?} public interface CGuiElementRenderState extends GuiElementRenderState { diff --git a/src/main/java/dev/isxander/controlify/utils/render/RenderUtils.java b/src/main/java/dev/isxander/controlify/impl/utils/render/RenderUtils.java similarity index 95% rename from src/main/java/dev/isxander/controlify/utils/render/RenderUtils.java rename to src/main/java/dev/isxander/controlify/impl/utils/render/RenderUtils.java index 912bb9be9..5de1781cd 100644 --- a/src/main/java/dev/isxander/controlify/utils/render/RenderUtils.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/render/RenderUtils.java @@ -4,7 +4,7 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.render; +package dev.isxander.controlify.impl.utils.render; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.renderer.RenderPipelines; diff --git a/src/main/java/dev/isxander/controlify/utils/render/elements/CircleElementRenderState.java b/src/main/java/dev/isxander/controlify/impl/utils/render/elements/CircleElementRenderState.java similarity index 92% rename from src/main/java/dev/isxander/controlify/utils/render/elements/CircleElementRenderState.java rename to src/main/java/dev/isxander/controlify/impl/utils/render/elements/CircleElementRenderState.java index 527cd9954..3109f6360 100644 --- a/src/main/java/dev/isxander/controlify/utils/render/elements/CircleElementRenderState.java +++ b/src/main/java/dev/isxander/controlify/impl/utils/render/elements/CircleElementRenderState.java @@ -4,11 +4,11 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.utils.render.elements; +package dev.isxander.controlify.impl.utils.render.elements; import com.mojang.blaze3d.vertex.VertexConsumer; -import dev.isxander.controlify.utils.render.BaseRenderState; -import dev.isxander.controlify.utils.render.CGuiElementRenderState; +import dev.isxander.controlify.impl.utils.render.BaseRenderState; +import dev.isxander.controlify.impl.utils.render.CGuiElementRenderState; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.util.Mth; import org.jspecify.annotations.NonNull; diff --git a/src/main/java/dev/isxander/controlify/virtualmouse/SnapUtils.java b/src/main/java/dev/isxander/controlify/impl/virtualmouse/SnapUtils.java similarity index 95% rename from src/main/java/dev/isxander/controlify/virtualmouse/SnapUtils.java rename to src/main/java/dev/isxander/controlify/impl/virtualmouse/SnapUtils.java index e0e1a9bf4..299c6c9bb 100644 --- a/src/main/java/dev/isxander/controlify/virtualmouse/SnapUtils.java +++ b/src/main/java/dev/isxander/controlify/impl/virtualmouse/SnapUtils.java @@ -4,9 +4,9 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.virtualmouse; +package dev.isxander.controlify.impl.virtualmouse; -import dev.isxander.controlify.api.vmousesnapping.SnapPoint; +import dev.isxander.controlify.api.screenop.SnapPoint; import dev.isxander.controlify.mixins.feature.virtualmouse.snapping.RecipeBookComponentAccessor; import dev.isxander.controlify.mixins.feature.virtualmouse.snapping.RecipeBookPageAccessor; import net.minecraft.client.gui.screens.recipebook.RecipeBookComponent; diff --git a/src/main/java/dev/isxander/controlify/wireless/LowBatteryNotifier.java b/src/main/java/dev/isxander/controlify/impl/wireless/LowBatteryNotifier.java similarity index 65% rename from src/main/java/dev/isxander/controlify/wireless/LowBatteryNotifier.java rename to src/main/java/dev/isxander/controlify/impl/wireless/LowBatteryNotifier.java index 1c89d4ba3..939f2c16b 100644 --- a/src/main/java/dev/isxander/controlify/wireless/LowBatteryNotifier.java +++ b/src/main/java/dev/isxander/controlify/impl/wireless/LowBatteryNotifier.java @@ -4,14 +4,14 @@ * * SPDX-License-Identifier: LGPL-3.0-or-later */ -package dev.isxander.controlify.wireless; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.battery.PowerState; -import dev.isxander.controlify.controller.battery.BatteryLevelComponent; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controllermanager.ControllerManager; -import dev.isxander.controlify.utils.MinecraftUtil; +package dev.isxander.controlify.impl.wireless; + +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.battery.PowerState; +import dev.isxander.controlify.impl.controller.battery.BatteryLevelComponent; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controllermanager.ControllerManager; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.network.chat.Component; import java.util.HashSet; @@ -35,18 +35,18 @@ public static void tick() { if (controllerManager == null) return; - for (ControllerEntity controller : controllerManager.getConnectedControllers()) { + for (ControllerImpl controller : controllerManager.getAllControllers()) { PowerState batteryLevel = controller.batteryLevel() .map(BatteryLevelComponent::getBatteryLevel) .orElse(new PowerState.Unknown()); String uid = controller.uid(); - if (batteryLevel instanceof PowerState.Depleting depleting && depleting.percent() <= 10) { + if (batteryLevel instanceof PowerState.Depleting(int percent) && percent <= 10) { if (!notifiedControllers.contains(uid)) { MinecraftUtil.sendToast( Component.translatable("controlify.toast.low_battery.title"), - Component.translatable("controlify.toast.low_battery.message", controller.name(), depleting.percent() + "%"), + Component.translatable("controlify.toast.low_battery.message", controller.name(), percent + "%"), true ); diff --git a/src/main/java/dev/isxander/controlify/mixins/core/ClientPacketListenerMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/ClientPacketListenerMixin.java index 73cf3dbd1..d9c5bb82a 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/ClientPacketListenerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/ClientPacketListenerMixin.java @@ -9,7 +9,7 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.sugar.Local; -import dev.isxander.controlify.ingame.ControllerPlayerMovement; +import dev.isxander.controlify.impl.ingame.ControllerPlayerMovement; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.client.player.LocalPlayer; diff --git a/src/main/java/dev/isxander/controlify/mixins/core/GuiMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/GuiMixin.java index bc4264d9d..5c3ed6a22 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/GuiMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/GuiMixin.java @@ -8,11 +8,10 @@ import com.llamalad7.mixinextras.injector.wrapmethod.WrapMethod; import com.llamalad7.mixinextras.injector.wrapoperation.Operation; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.utils.InitialScreenRegistryDuck; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.controlify.utils.MouseMinecraftCallNotifier; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.utils.InitialScreenRegistryDuck; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.impl.utils.MouseMinecraftCallNotifier; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import org.spongepowered.asm.mixin.Mixin; @@ -29,8 +28,8 @@ //? if >=26.2 { net.minecraft.client.gui.Gui.class //?} else { - /*Minecraft.class - *///?} + //Minecraft.class + //?} ) public class GuiMixin implements InitialScreenRegistryDuck { @Unique private final List> initialScreenCallbacks = new ArrayList<>(); @@ -45,21 +44,11 @@ private void notifyInjectionToNotRun(Screen screen, CallbackInfo ci) { ((MouseMinecraftCallNotifier) Minecraft.getInstance().mouseHandler).controlify$imFromMinecraftSetScreen(); } - /** - * Without this, the mouse would be left in the middle of the - * screen, hovering over whatever is there which would look wrong - * as there is a focus as well. - */ - @Inject( - method = "setScreen", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/client/gui/screens/Screen;init(II)V", - shift = At.Shift.AFTER - ) - ) + // Run after initialization and the final mouse-position resync, which would + // otherwise restore on-screen coordinates and make widgets appear hovered. + @Inject(method = "setScreen", at = @At("RETURN")) private void hideMouseAfterRelease(Screen screen, CallbackInfo ci) { - if (ControlifyApi.get().currentInputMode().isController()) { + if (Controlify.instance().currentInputMode().isController()) { Controlify.instance().hideMouse(true, true); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/core/HudMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/HudMixin.java index 9266b7b96..df3497df2 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/HudMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/HudMixin.java @@ -7,8 +7,8 @@ package dev.isxander.controlify.mixins.core; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.ingame.InGameInputHandler; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.ingame.InGameInputHandler; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -16,8 +16,8 @@ //? if >=26.2 { net.minecraft.client.gui.Hud.class //?} else { - /*net.minecraft.client.gui.Gui.class - *///?} + //net.minecraft.client.gui.Gui.class + //?} ) public class HudMixin { @ModifyExpressionValue(method = "extractTabList", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/KeyMapping;isDown()Z")) diff --git a/src/main/java/dev/isxander/controlify/mixins/core/InputConstantsMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/InputConstantsMixin.java index e3fdb464b..f3eb1e7a3 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/InputConstantsMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/InputConstantsMixin.java @@ -23,7 +23,7 @@ private static void ensureMouseVisible(CallbackInfo ci) { } } //?} else { -/*@Mixin(dev.isxander.controlify.utils.DummyMixinTarget.class) +/*@Mixin(dev.isxander.controlify.impl.utils.DummyMixinTarget.class) public class InputConstantsMixin { } *///?} diff --git a/src/main/java/dev/isxander/controlify/mixins/core/KeyboardHandlerMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/KeyboardHandlerMixin.java index 638c93cbc..38a9e5682 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/KeyboardHandlerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/KeyboardHandlerMixin.java @@ -12,8 +12,8 @@ /*import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import com.mojang.blaze3d.platform.Window; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.api.InputMode; import net.minecraft.client.KeyboardHandler; import net.minecraft.client.Minecraft; import org.lwjgl.glfw.*; @@ -68,7 +68,7 @@ private void wrapKeyboardEvents( } } *///?} else { -@Mixin(dev.isxander.controlify.utils.DummyMixinTarget.class) +@Mixin(dev.isxander.controlify.impl.utils.DummyMixinTarget.class) public class KeyboardHandlerMixin { } //?} diff --git a/src/main/java/dev/isxander/controlify/mixins/core/MinecraftMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/MinecraftMixin.java index 3eba1f2a2..d040b0ccf 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/MinecraftMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/MinecraftMixin.java @@ -6,9 +6,11 @@ */ package dev.isxander.controlify.mixins.core; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controllermanager.ControllerManager; -import dev.isxander.controlify.utils.animation.impl.Animator; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controllermanager.ControllerCoordinator; +import dev.isxander.controlify.impl.utils.animation.impl.Animator; import net.minecraft.CrashReport; import net.minecraft.client.DeltaTracker; import net.minecraft.client.Minecraft; @@ -39,8 +41,15 @@ private void initControlifyNow(CallbackInfo ci) { } } + @WrapOperation(method = "run", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;runTick(Z)V")) + private void wrapUpdate(Minecraft instance, boolean advanceGameTime, Operation original) { + Controlify.instance().update(getTickDelta()); + original.call(instance, advanceGameTime); + Controlify.instance().postUpdate(getTickDelta()); + } + @Inject(method = "runTick", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MouseHandler;handleAccumulatedMovement()V")) - private void doPlayerLook(boolean advanceGameTime, CallbackInfo ci) { + private void doPlayerLook(CallbackInfo ci) { Controlify.instance().inGameInputHandler().ifPresent(ih -> ih.processPlayerLook(getTickDelta())); } @@ -52,7 +61,7 @@ private void doPlayerLook(boolean advanceGameTime, CallbackInfo ci) { ) ) private void onMinecraftClose(CallbackInfo ci) { - Controlify.instance().getControllerManager().ifPresent(ControllerManager::close); + Controlify.instance().getControllerCoordinator().ifPresent(ControllerCoordinator::close); } @Inject( diff --git a/src/main/java/dev/isxander/controlify/mixins/core/MouseHandlerMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/MouseHandlerMixin.java index 1ebe42f4b..a20fed736 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/MouseHandlerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/MouseHandlerMixin.java @@ -7,13 +7,8 @@ package dev.isxander.controlify.mixins.core; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import com.llamalad7.mixinextras.injector.wrapoperation.Operation; -import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; -import com.mojang.blaze3d.platform.Window; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.utils.MouseMinecraftCallNotifier; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.utils.MouseMinecraftCallNotifier; import net.minecraft.client.Minecraft; import net.minecraft.client.MouseHandler; import org.spongepowered.asm.mixin.Final; @@ -25,7 +20,11 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; //? if <26.3 { -/*import org.lwjgl.glfw.GLFWCursorPosCallbackI; +/*import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.mojang.blaze3d.platform.Window; +import dev.isxander.controlify.api.InputMode; +import org.lwjgl.glfw.GLFWCursorPosCallbackI; import org.lwjgl.glfw.GLFWDropCallbackI; import org.lwjgl.glfw.GLFWMouseButtonCallbackI; import org.lwjgl.glfw.GLFWScrollCallbackI; @@ -92,12 +91,12 @@ private void wrapMouseEvents( //? if >=26.3 { target = "Lcom/mojang/blaze3d/platform/InputConstants;releaseMouse(Lcom/mojang/blaze3d/platform/Window;DD)V" //?} else { - /*target = "Lcom/mojang/blaze3d/platform/InputConstants;grabOrReleaseMouse(Lcom/mojang/blaze3d/platform/Window;IDD)V" - *///?} + //target = "Lcom/mojang/blaze3d/platform/InputConstants;grabOrReleaseMouse(Lcom/mojang/blaze3d/platform/Window;IDD)V" + //?} ) ) private void moveMouseIfNecessary(CallbackInfo ci) { - if (!controlify$calledFromMinecraftSetScreen && ControlifyApi.get().currentInputMode().isController()) { + if (!controlify$calledFromMinecraftSetScreen && Controlify.instance().currentInputMode().isController()) { Controlify.instance().hideMouse(true, true); } } @@ -110,7 +109,7 @@ private void resetCalledFromMinecraftSetScreen(CallbackInfo ci) { @ModifyExpressionValue(method = "grabMouse", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;isWindowActive()Z")) private boolean passWindowActiveCheckIfOOFInputIsOn(boolean isWindowActive) { - return isWindowActive || (ControlifyApi.get().currentInputMode().isController() && Controlify.instance().config().getSettings().globalSettings().outOfFocusInput); + return isWindowActive || (Controlify.instance().currentInputMode().isController() && Controlify.instance().config().getSettings().globalSettings().outOfFocusInput); } @Override diff --git a/src/main/java/dev/isxander/controlify/mixins/core/SDLEventHandlerMixin.java b/src/main/java/dev/isxander/controlify/mixins/core/SDLEventHandlerMixin.java index fdbcedc8a..5c3589a68 100644 --- a/src/main/java/dev/isxander/controlify/mixins/core/SDLEventHandlerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/core/SDLEventHandlerMixin.java @@ -10,8 +10,8 @@ //? if >=26.3 { import com.mojang.blaze3d.platform.SDLEventHandler; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.api.InputMode; import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Shadow; @@ -63,6 +63,6 @@ private void onMouseInput(CallbackInfo ci) { } } //?} else { -/*@Mixin(dev.isxander.controlify.utils.DummyMixinTarget.class) +/*@Mixin(dev.isxander.controlify.impl.utils.DummyMixinTarget.class) public class SDLEventHandlerMixin { } *///?} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/accessibility/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/accessibility/LocalPlayerMixin.java index 2785b7862..0a8c3e4c7 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/accessibility/LocalPlayerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/accessibility/LocalPlayerMixin.java @@ -6,8 +6,9 @@ */ package dev.isxander.controlify.mixins.feature.accessibility; +import dev.isxander.controlify.impl.Controlify; + import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.ControlifyApi; import net.minecraft.client.player.LocalPlayer; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -16,8 +17,8 @@ public class LocalPlayerMixin { @ModifyExpressionValue(method = "sendPosition", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/OptionInstance;get()Ljava/lang/Object;")) private Object shouldUseAutoJump(Object keyboardAutoJump) { - if (ControlifyApi.get().currentInputMode().isController()) { - return ControlifyApi.get().getCurrentController() + if (Controlify.instance().currentInputMode().isController()) { + return Controlify.instance().getCurrentController() .map(controller -> controller.settings().generic.autoJump) .orElse(false); } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/bind/GuiMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/bind/GuiMixin.java index 3dfedee28..c8547d851 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/bind/GuiMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/bind/GuiMixin.java @@ -6,9 +6,11 @@ */ package dev.isxander.controlify.mixins.feature.bind; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.input.InputComponent; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.controller.input.InputComponentImpl; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -16,15 +18,15 @@ @Mixin( //? if >=26.2 { - net.minecraft.client.gui.Gui.class + Gui.class //?} else { - /*net.minecraft.client.Minecraft.class - *///?} + //Minecraft.class + //?} ) public class GuiMixin { @Inject(method = "setScreen", at = @At("HEAD")) private void notifyBindGuiOutputOfScreenChange(CallbackInfo ci) { - ControlifyApi.get().getCurrentController().flatMap(ControllerEntity::input) - .ifPresent(InputComponent::notifyGuiPressOutputsOfNavigate); + Controlify.instance().getCurrentController().map(ControllerImpl::input) + .ifPresent(InputComponentImpl::onGuiNavigate); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/bind/HudMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/bind/HudMixin.java index 91ea5f781..16f9865c9 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/bind/HudMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/bind/HudMixin.java @@ -6,8 +6,8 @@ */ package dev.isxander.controlify.mixins.feature.bind; -import dev.isxander.controlify.gui.screen.RadialMenuScreen; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.gui.screen.RadialMenuScreen; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -17,8 +17,8 @@ //? if >=26.2 { net.minecraft.client.gui.Hud.class //?} else { - /*net.minecraft.client.gui.Gui.class - *///?} + //net.minecraft.client.gui.Gui.class + //?} ) public class HudMixin { diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/bind/KeyMappingMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/bind/KeyMappingMixin.java index 19fb2e0ef..8182164ed 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/bind/KeyMappingMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/bind/KeyMappingMixin.java @@ -6,18 +6,13 @@ */ package dev.isxander.controlify.mixins.feature.bind; -import dev.isxander.controlify.bindings.KeyMappingHandle; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.impl.action.KeyMappingHandle; import net.minecraft.client.KeyMapping; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; - -import java.util.function.BooleanSupplier; @Mixin(KeyMapping.class) public class KeyMappingMixin implements KeyMappingHandle { - @Shadow private int clickCount; @@ -25,21 +20,12 @@ public class KeyMappingMixin implements KeyMappingHandle { private boolean isDown; @Override - public void controlify$setPressed(boolean isDown) { - if (isDown) { - this.isDown = true; - this.clickCount++; - } else { - this.isDown = false; - } + public void controlify$click() { + this.clickCount++; } @Override - public void controlify$addToggleCondition(ControllerEntity controller, BooleanSupplier condition) { - - } - - @Unique protected void incClickCount() { - this.clickCount++; + public void controlify$setDown(boolean down) { + this.isDown = down; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/bind/ToggleKeyMappingMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/bind/ToggleKeyMappingMixin.java deleted file mode 100644 index edb3d1ba8..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/bind/ToggleKeyMappingMixin.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.bind; - -import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.bindings.KeyMappingHandle; -import dev.isxander.controlify.controller.ControllerEntity; -import net.minecraft.client.ToggleKeyMapping; -import org.spongepowered.asm.mixin.*; -import org.spongepowered.asm.mixin.injection.At; - -import java.util.function.BooleanSupplier; - -@Mixin(ToggleKeyMapping.class) -public abstract class ToggleKeyMappingMixin extends KeyMappingMixin implements KeyMappingHandle { - - @Unique private BooleanSupplier controlifyToggleSupplier = () -> false; - - @Shadow - public abstract void setDown(boolean value); - - @Override - public void controlify$setPressed(boolean isDown) { - if (isDown) - this.incClickCount(); - this.setDown(isDown); - } - - @ModifyExpressionValue(method = "setDown", at = @At(value = "INVOKE", target = "Ljava/util/function/BooleanSupplier;getAsBoolean()Z")) - private boolean modifyToggleMode(boolean vanillaToggleMode) { - if (Controlify.instance().currentInputMode().isController()) { - return controlifyToggleSupplier.getAsBoolean(); - } - return vanillaToggleMode; - } - - @Override - public void controlify$addToggleCondition(ControllerEntity controller, BooleanSupplier condition) { - BooleanSupplier oldCondition = controlifyToggleSupplier; - controlifyToggleSupplier = () -> { - boolean thisToggle = condition.getAsBoolean() - && Controlify.instance().currentInputMode().isController() - && Controlify.instance().getCurrentController().map(current -> controller == current).orElse(false); - return oldCondition.getAsBoolean() || thisToggle; - }; - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/font/KeybindContentsMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/font/KeybindContentsMixin.java index 2463d8917..32a432bb4 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/font/KeybindContentsMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/font/KeybindContentsMixin.java @@ -9,10 +9,10 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import com.llamalad7.mixinextras.sugar.Local; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.font.ActionGlyphsImpl; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FontDescription; import net.minecraft.network.chat.Style; import net.minecraft.network.chat.contents.KeybindContents; import net.minecraft.resources.Identifier; @@ -22,7 +22,6 @@ import org.spongepowered.asm.mixin.injection.At; import java.util.Optional; -import net.minecraft.network.chat.FontDescription; @Mixin(KeybindContents.class) public class KeybindContentsMixin { @@ -31,17 +30,16 @@ public class KeybindContentsMixin { private String name; @WrapOperation(method = "visit(Lnet/minecraft/network/chat/FormattedText$StyledContentConsumer;Lnet/minecraft/network/chat/Style;)Ljava/util/Optional;", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/chat/contents/KeybindContents;getNestedComponent()Lnet/minecraft/network/chat/Component;")) - private Component testVisitWithStyle(KeybindContents instance, Operation original, @Local(argsOnly = true) Style style) { + private Component testVisitWithStyle(KeybindContents instance, Operation original, @Local(argsOnly = true, name = "currentStyle") Style style) { boolean wrapperFont = style.getFont() instanceof FontDescription.Resource(Identifier font) - && BindingFontHelper.WRAPPER_FONT.equals(font); + && ActionGlyphsImpl.WRAPPER_FONT.equals(font); if (wrapperFont) { - Optional inputText = ControlifyApi.get().getCurrentController() - .filter(c -> c.input().isPresent()) + Optional inputText = Controlify.instance().getCurrentController() .map(c -> Controlify.instance().inputFontMapper() - .getComponentFromBinding( - c.info().type().namespace(), - c.input().get().getBinding(Identifier.tryParse(this.name)) + .getComponentFromAction( + c.info().type().id(), + c.input().getAction(Identifier.tryParse(this.name)) ) ); if (inputText.isPresent()) { diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/font/TranslatableContentsMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/font/TranslatableContentsMixin.java index 7822079a4..33eb946cc 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/font/TranslatableContentsMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/font/TranslatableContentsMixin.java @@ -7,8 +7,8 @@ package dev.isxander.controlify.mixins.feature.font; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.font.ActionGlyphsImpl; import net.minecraft.network.chat.contents.TranslatableContents; import org.objectweb.asm.Opcodes; import org.spongepowered.asm.mixin.Mixin; @@ -25,9 +25,9 @@ public class TranslatableContentsMixin { ) ) private String replaceControllerActiveKey(String originalKey) { - if (BindingFontHelper.PLACEHOLDER_CONTROLLER_ACTIVE_KEY.equals(originalKey)) { - if (ControlifyApi.get().currentInputMode().isController()) { - return BindingFontHelper.PLACEHOLDER_KEY; + if (ActionGlyphsImpl.PLACEHOLDER_CONTROLLER_ACTIVE_KEY.equals(originalKey)) { + if (Controlify.instance().currentInputMode().isController()) { + return ActionGlyphsImpl.PLACEHOLDER_KEY; } else { // because the key doesn't exist, it will rely on the fallback text provided by this translatable contents return originalKey; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/guide/ingame/ClientPacketListenerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/guide/ingame/ClientPacketListenerMixin.java index 1e1c1768f..2fa398a92 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/guide/ingame/ClientPacketListenerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/guide/ingame/ClientPacketListenerMixin.java @@ -8,8 +8,8 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.gui.guide.InGameButtonGuide; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.gui.guide.InGameButtonGuide; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.network.protocol.game.ClientboundLoginPacket; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/guide/ingame/HudMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/guide/ingame/HudMixin.java index a347853ee..90d255f3a 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/guide/ingame/HudMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/guide/ingame/HudMixin.java @@ -6,8 +6,8 @@ */ package dev.isxander.controlify.mixins.feature.guide.ingame; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.gui.guide.InGameButtonGuide; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.gui.guide.InGameButtonGuide; import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; @@ -20,8 +20,8 @@ //? if >=26.2 { net.minecraft.client.gui.Hud.class //?} else { - /*net.minecraft.client.gui.Gui.class - *///?} + //net.minecraft.client.gui.Gui.class + //?} ) public class HudMixin { @Shadow @Final private Minecraft minecraft; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/guide/screen/AbstractButtonMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/guide/screen/AbstractButtonMixin.java index 28ad2d86e..e14579278 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/guide/screen/AbstractButtonMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/guide/screen/AbstractButtonMixin.java @@ -7,8 +7,8 @@ package dev.isxander.controlify.mixins.feature.guide.screen; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.gui.ButtonGuideRenderer; +import dev.isxander.controlify.api.action.Action; +import dev.isxander.controlify.impl.gui.ButtonGuideRenderer; import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap; import net.minecraft.client.gui.components.AbstractButton; import net.minecraft.network.chat.Component; @@ -23,7 +23,7 @@ @Mixin(AbstractButton.class) public abstract class AbstractButtonMixin extends AbstractWidgetMixin implements ButtonGuideRenderer { @Unique private RenderData renderData = null; - @Unique private final Map controllerMessages = new Object2ObjectArrayMap<>(2); + @Unique private final Map controllerMessages = new Object2ObjectArrayMap<>(2); @ModifyExpressionValue(method = "extractDefaultLabel", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/components/AbstractButton;getMessage()Lnet/minecraft/network/chat/Component;")) protected Component modifyRenderedLabel(Component originalMessage) { @@ -42,8 +42,9 @@ protected void catchMessageSet(Component message, CallbackInfo ci) { } @Unique private Component getControllerMessage(Component actualLabel) { - if (!shouldRender()) + if (!shouldRender()) { return actualLabel; + } return getBind().map(bind -> controllerMessages .computeIfAbsent(bind, b -> renderData.getControllerMessage(b, actualLabel))) @@ -56,8 +57,10 @@ && isActive() && renderData.shouldRender((AbstractButton) (Object) this); } - @Unique private Optional getBind() { - if (renderData == null) return Optional.empty(); - return renderData.getBind(); + @Unique private Optional getBind() { + if (renderData == null) { + return Optional.empty(); + } + return renderData.getAction(); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/guide/screen/TabNavigationBarMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/guide/screen/TabNavigationBarMixin.java index 707da705e..cbdbf7770 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/guide/screen/TabNavigationBarMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/guide/screen/TabNavigationBarMixin.java @@ -7,9 +7,10 @@ package dev.isxander.controlify.mixins.feature.guide.screen; import com.google.common.collect.ImmutableList; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.controller.ControllerImpl; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; @@ -31,8 +32,8 @@ public class TabNavigationBarMixin { //? if >=26.2 { @Inject(method = "extractWidgetRenderState", at = @At("RETURN")) //?} else { - /*@Inject(method = "extractRenderState", at = @At("RETURN")) - *///?} + //@Inject(method = "extractRenderState", at = @At("RETURN")) + //?} private void renderControllerButtonOverlay(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta, CallbackInfo ci) { if (Controlify.instance().currentInputMode().isController()) { Controlify.instance().getCurrentController().ifPresent(c -> { @@ -43,19 +44,21 @@ private void renderControllerButtonOverlay(GuiGraphicsExtractor graphics, int mo } } - @Unique private void renderControllerButtonOverlay(GuiGraphicsExtractor graphics, ControllerEntity controller) { - if (tabButtons.size() <= 1) return; // no controls necessary + @Unique private void renderControllerButtonOverlay(GuiGraphicsExtractor graphics, ControllerImpl controller) { + if (tabButtons.size() <= 1) { + return; // no controls necessary + } TabButton firstTab = tabButtons.get(0); TabButton lastTab = tabButtons.get(tabButtons.size() - 1); Font font = Minecraft.getInstance().font; - Component prevTabText = ControlifyBindings.GUI_PREV_TAB.on(controller).inputGlyph(); + Component prevTabText = ActionGlyphs.action(ControlifyActions.GUI_PREV_TAB.actionId()); int prevTabTextWidth = font.width(prevTabText); graphics.text(font, prevTabText, firstTab.getX() - 2 - prevTabTextWidth, firstTab.getY() / 2 + font.lineHeight / 2, 0xFFFFFFFF); - Component nextTabText = ControlifyBindings.GUI_NEXT_TAB.on(controller).inputGlyph(); + Component nextTabText = ActionGlyphs.action(ControlifyActions.GUI_NEXT_TAB.actionId()); graphics.text(font, nextTabText, lastTab.getX() + lastTab.getWidth() + 2, lastTab.getY() / 2 + font.lineHeight / 2, 0xFFFFFFFF); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/input/MinecraftMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/input/MinecraftMixin.java index 27423f9f5..ab72ea92f 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/input/MinecraftMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/input/MinecraftMixin.java @@ -7,7 +7,7 @@ package dev.isxander.controlify.mixins.feature.input; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.ingame.PickBlockAccessor; +import dev.isxander.controlify.impl.ingame.PickBlockAccessor; import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/oofinput/MinecraftMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/oofinput/MinecraftMixin.java index 8ce668950..f2bc51be6 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/oofinput/MinecraftMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/oofinput/MinecraftMixin.java @@ -7,7 +7,7 @@ package dev.isxander.controlify.mixins.feature.oofinput; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.impl.Controlify; import net.minecraft.client.Minecraft; import org.objectweb.asm.Opcodes; import org.spongepowered.asm.mixin.Mixin; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/patches/analogueboat/AbstractBoatMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/patches/analogueboat/AbstractBoatMixin.java index a26853db1..155fb123f 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/patches/analogueboat/AbstractBoatMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/patches/analogueboat/AbstractBoatMixin.java @@ -7,7 +7,7 @@ package dev.isxander.controlify.mixins.feature.patches.analogueboat; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.fixes.boatfix.AnalogBoatInput; +import dev.isxander.controlify.impl.boatfix.AnalogBoatInput; import net.minecraft.world.entity.vehicle.boat.AbstractBoat; import org.objectweb.asm.Opcodes; import org.spongepowered.asm.mixin.Mixin; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/patches/analogueboat/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/patches/analogueboat/LocalPlayerMixin.java index 6bfa0dcd5..8ba72694e 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/patches/analogueboat/LocalPlayerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/patches/analogueboat/LocalPlayerMixin.java @@ -8,11 +8,10 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.fixes.boatfix.AnalogBoatInput; -import dev.isxander.controlify.ingame.InGameInputHandler; -import dev.isxander.controlify.utils.MthExt; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.boatfix.AnalogBoatInput; +import dev.isxander.controlify.impl.ingame.InGameInputHandler; +import dev.isxander.controlify.impl.utils.MthExt; import net.minecraft.client.player.ClientInput; import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.phys.Vec2; @@ -37,7 +36,7 @@ private void useAnalogInput( boolean left, boolean right, boolean up, boolean down, Operation original ) { - if (ControlifyApi.get().currentInputMode().isController() && !Controlify.instance().config().getSettings().globalSettings().shouldUseKeyboardMovement()) { + if (Controlify.instance().currentInputMode().isController() && !Controlify.instance().config().getSettings().globalSettings().shouldUseKeyboardMovement()) { Vec2 moveVec = InGameInputHandler.getMoveVec(input); float forwardImpulse = moveVec.y; float rightImpulse = -moveVec.x; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/ClientPacketListenerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/ClientPacketListenerMixin.java index 8ba47c34a..978f2d668 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/ClientPacketListenerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/ClientPacketListenerMixin.java @@ -9,10 +9,9 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.network.chat.Component; import org.spongepowered.asm.mixin.Mixin; @@ -30,10 +29,10 @@ public class ClientPacketListenerMixin { @Expression("?.keyShift.getTranslatedKeyMessage()") @ModifyExpressionValue(method = "handleSetEntityPassengersPacket", at = @At("MIXINEXTRAS:EXPRESSION")) private Component useControllerTextForSneakTip(Component original) { - return ControlifyApi.get().getCurrentController() - .flatMap(c -> ControlifyApi.get().currentInputMode().isController() ? Optional.of(c) : Optional.empty()) - .flatMap(c -> Optional.ofNullable(ControlifyBindings.SNEAK.on(c))) - .map(InputBinding::inputGlyph) + return Controlify.instance().getCurrentController() + .flatMap(c -> Controlify.instance().currentInputMode().isController() ? Optional.of(c) : Optional.empty()) + .flatMap(c -> Optional.ofNullable(ControlifyActions.SNEAK.onOrNull(c))) + .map(a -> ActionGlyphs.action(a.spec().id())) .orElse(original); } @@ -42,7 +41,7 @@ private Component useControllerTextForSneakTip(Component original) { @Expression("?.keyUp.getTranslatedKeyMessage()") @ModifyExpressionValue(method = "handleGameEvent", at = @At("MIXINEXTRAS:EXPRESSION")) private Component useTutorialUpGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.WALK_FORWARD.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.WALK_FORWARD.actionId(), original); } @Definition(id = "keyLeft", field = "Lnet/minecraft/client/Options;keyLeft:Lnet/minecraft/client/KeyMapping;") @@ -50,7 +49,7 @@ private Component useTutorialUpGlyph(Component original) { @Expression("?.keyLeft.getTranslatedKeyMessage()") @ModifyExpressionValue(method = "handleGameEvent", at = @At("MIXINEXTRAS:EXPRESSION")) private Component useTutorialLeftGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.WALK_LEFT.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.WALK_LEFT.actionId(), original); } @Definition(id = "keyDown", field = "Lnet/minecraft/client/Options;keyDown:Lnet/minecraft/client/KeyMapping;") @@ -58,7 +57,7 @@ private Component useTutorialLeftGlyph(Component original) { @Expression("?.keyDown.getTranslatedKeyMessage()") @ModifyExpressionValue(method = "handleGameEvent", at = @At("MIXINEXTRAS:EXPRESSION")) private Component useTutorialDownGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.WALK_BACKWARD.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.WALK_BACKWARD.actionId(), original); } @Definition(id = "keyRight", field = "Lnet/minecraft/client/Options;keyRight:Lnet/minecraft/client/KeyMapping;") @@ -66,7 +65,7 @@ private Component useTutorialDownGlyph(Component original) { @Expression("?.keyRight.getTranslatedKeyMessage()") @ModifyExpressionValue(method = "handleGameEvent", at = @At("MIXINEXTRAS:EXPRESSION")) private Component useTutorialRightGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.WALK_RIGHT.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.WALK_RIGHT.actionId(), original); } @Definition(id = "keyJump", field = "Lnet/minecraft/client/Options;keyJump:Lnet/minecraft/client/KeyMapping;") @@ -74,7 +73,7 @@ private Component useTutorialRightGlyph(Component original) { @Expression("?.keyJump.getTranslatedKeyMessage()") @ModifyExpressionValue(method = "handleGameEvent", at = @At("MIXINEXTRAS:EXPRESSION")) private Component useTutorialJumpGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.JUMP.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.JUMP.actionId(), original); } @Definition(id = "keyInventory", field = "Lnet/minecraft/client/Options;keyInventory:Lnet/minecraft/client/KeyMapping;") @@ -82,7 +81,7 @@ private Component useTutorialJumpGlyph(Component original) { @Expression("?.keyInventory.getTranslatedKeyMessage()") @ModifyExpressionValue(method = "handleGameEvent", at = @At("MIXINEXTRAS:EXPRESSION")) private Component useTutorialInventoryGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.INVENTORY.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.INVENTORY.actionId(), original); } @Definition(id = "keyScreenshot", field = "Lnet/minecraft/client/Options;keyScreenshot:Lnet/minecraft/client/KeyMapping;") @@ -90,6 +89,6 @@ private Component useTutorialInventoryGlyph(Component original) { @Expression("?.keyScreenshot.getTranslatedKeyMessage()") @ModifyExpressionValue(method = "handleGameEvent", at = @At("MIXINEXTRAS:EXPRESSION")) private Component useTutorialScreenshotGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.TAKE_SCREENSHOT.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.TAKE_SCREENSHOT.actionId(), original); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/MovementTutorialStepInstanceMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/MovementTutorialStepInstanceMixin.java index 073025ab0..b4cdb3fcf 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/MovementTutorialStepInstanceMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/MovementTutorialStepInstanceMixin.java @@ -9,8 +9,8 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.action.ControlifyActions; import net.minecraft.client.tutorial.MovementTutorialStepInstance; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; @@ -23,35 +23,35 @@ public class MovementTutorialStepInstanceMixin { @Expression("key('forward')") @ModifyExpressionValue(method = "", at = @At("MIXINEXTRAS:EXPRESSION")) private static Component useForwardGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.WALK_FORWARD.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.WALK_FORWARD.actionId(), original); } @Definition(id = "key", method = "Lnet/minecraft/client/tutorial/Tutorial;key(Ljava/lang/String;)Lnet/minecraft/network/chat/Component;") @Expression("key('left')") @ModifyExpressionValue(method = "", at = @At("MIXINEXTRAS:EXPRESSION")) private static Component useLeftGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.WALK_LEFT.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.WALK_LEFT.actionId(), original); } @Definition(id = "key", method = "Lnet/minecraft/client/tutorial/Tutorial;key(Ljava/lang/String;)Lnet/minecraft/network/chat/Component;") @Expression("key('back')") @ModifyExpressionValue(method = "", at = @At("MIXINEXTRAS:EXPRESSION")) private static Component useBackGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.WALK_BACKWARD.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.WALK_BACKWARD.actionId(), original); } @Definition(id = "key", method = "Lnet/minecraft/client/tutorial/Tutorial;key(Ljava/lang/String;)Lnet/minecraft/network/chat/Component;") @Expression("key('right')") @ModifyExpressionValue(method = "", at = @At("MIXINEXTRAS:EXPRESSION")) private static Component useRightGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.WALK_RIGHT.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.WALK_RIGHT.actionId(), original); } @Definition(id = "key", method = "Lnet/minecraft/client/tutorial/Tutorial;key(Ljava/lang/String;)Lnet/minecraft/network/chat/Component;") @Expression("key('jump')") @ModifyExpressionValue(method = "", at = @At("MIXINEXTRAS:EXPRESSION")) private static Component useJumpGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.JUMP.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.JUMP.actionId(), original); } @Definition(id = "translatable", method = "Lnet/minecraft/network/chat/Component;translatable(Ljava/lang/String;)Lnet/minecraft/network/chat/MutableComponent;") diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/OpenInventoryTutorialStepMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/OpenInventoryTutorialStepMixin.java index 3a7455452..5d50c2971 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/OpenInventoryTutorialStepMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/OpenInventoryTutorialStepMixin.java @@ -9,8 +9,8 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.action.ControlifyActions; import net.minecraft.client.tutorial.OpenInventoryTutorialStep; import net.minecraft.network.chat.Component; import org.spongepowered.asm.mixin.Mixin; @@ -22,6 +22,6 @@ public class OpenInventoryTutorialStepMixin { @Expression("key('inventory')") @ModifyExpressionValue(method = "", at = @At("MIXINEXTRAS:EXPRESSION")) private static Component useGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.INVENTORY.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.INVENTORY.actionId(), original); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/PunchTreeTutorialStepInstanceMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/PunchTreeTutorialStepInstanceMixin.java index a1d01749a..5a42ddb49 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/PunchTreeTutorialStepInstanceMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/patches/keymessages/PunchTreeTutorialStepInstanceMixin.java @@ -9,8 +9,8 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.font.BindingFontHelper; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.action.ControlifyActions; import net.minecraft.client.tutorial.PunchTreeTutorialStepInstance; import net.minecraft.network.chat.Component; import org.spongepowered.asm.mixin.Mixin; @@ -22,6 +22,6 @@ public class PunchTreeTutorialStepInstanceMixin { @Expression("key('attack')") @ModifyExpressionValue(method = "", at = @At("MIXINEXTRAS:EXPRESSION")) private static Component useGlyph(Component original) { - return BindingFontHelper.bindingWithActiveFallback(ControlifyBindings.ATTACK.bindId(), original); + return ActionGlyphs.actionWithActiveFallback(ControlifyActions.ATTACK.actionId(), original); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/patches/sprintfix/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/patches/sprintfix/LocalPlayerMixin.java index b5b5f7956..1cf5c4c6f 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/patches/sprintfix/LocalPlayerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/patches/sprintfix/LocalPlayerMixin.java @@ -11,7 +11,7 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import com.llamalad7.mixinextras.sugar.Local; -import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.impl.Controlify; import net.minecraft.client.player.ClientInput; import net.minecraft.client.player.LocalPlayer; import org.spongepowered.asm.mixin.Mixin; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/reacharound/MinecraftMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/reacharound/MinecraftMixin.java index a4eb82d50..27335390c 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/reacharound/MinecraftMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/reacharound/MinecraftMixin.java @@ -7,7 +7,7 @@ package dev.isxander.controlify.mixins.feature.reacharound; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.reacharound.ReachAroundHandler; +import dev.isxander.controlify.impl.reacharound.ReachAroundHandler; import net.minecraft.client.Minecraft; import net.minecraft.world.entity.Entity; import net.minecraft.world.phys.HitResult; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/MinecraftMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/MinecraftMixin.java index 9e5bbe5e2..ead411034 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/MinecraftMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/MinecraftMixin.java @@ -6,8 +6,9 @@ */ package dev.isxander.controlify.mixins.feature.rumble; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.impl.Controlify; + +import dev.isxander.controlify.impl.controller.ControllerImpl; import net.minecraft.client.Minecraft; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -18,8 +19,8 @@ public class MinecraftMixin { @Inject(method = "disconnect(Lnet/minecraft/client/gui/screens/Screen;Z)V", at = @At("HEAD")) private void clearRumbleEffects(CallbackInfo ci) { - ControlifyApi.get().getCurrentController() - .flatMap(ControllerEntity::rumble) + Controlify.instance().getCurrentController() + .flatMap(ControllerImpl::rumble) .ifPresent(controller -> controller.rumbleManager().clearEffects()); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/blockbreak/MultiPlayerGameModeMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/blockbreak/MultiPlayerGameModeMixin.java index 04c2f5082..109fbaf97 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/blockbreak/MultiPlayerGameModeMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/blockbreak/MultiPlayerGameModeMixin.java @@ -6,13 +6,15 @@ */ package dev.isxander.controlify.mixins.feature.rumble.blockbreak; +import dev.isxander.controlify.impl.Controlify; + import com.llamalad7.mixinextras.injector.ModifyExpressionValue; import com.llamalad7.mixinextras.sugar.Local; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.ContinuousRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.utils.Easings; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.Easings; import net.minecraft.client.multiplayer.MultiPlayerGameMode; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; @@ -25,9 +27,12 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import java.util.concurrent.atomic.AtomicBoolean; + @Mixin(MultiPlayerGameMode.class) public class MultiPlayerGameModeMixin { - @Unique private ContinuousRumbleEffect blockBreakRumble = null; + @Unique private RumbleEffectInstance blockBreakRumble = null; + @Unique private AtomicBoolean blockBreakStopped; @Inject( method = "lambda$startDestroyBlock$1", @@ -74,21 +79,22 @@ private boolean onAbortBreakingBlock(boolean original) { @Unique private void startRumble(BlockState state) { stopRumble(); - var effect = ContinuousRumbleEffect.builder() + var stopped = new AtomicBoolean(); + blockBreakStopped = stopped; + var effect = RumbleEffect.continuous() .byTick(tick -> new RumbleState( 0.02f + (float)Easings.easeInQuad(Math.min(1, state.getBlock().defaultDestroyTime() / 20f)) * 0.25f, 0.01f )) .minTime(1) - .build(); + .stopCondition(stopped::get); - blockBreakRumble = effect; - ControlifyApi.get().playRumbleEffect(RumbleSource.INTERACTION, effect); + blockBreakRumble = Controlify.instance().playRumbleEffect(CUtil.rl("block_break"), effect).orElse(null); } @Unique private void stopRumble() { if (blockBreakRumble != null) { - blockBreakRumble.stop(); + blockBreakStopped.set(true); blockBreakRumble = null; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/damage/ClientPacketListenerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/damage/ClientPacketListenerMixin.java index f8257a718..6fa6c2cc1 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/damage/ClientPacketListenerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/damage/ClientPacketListenerMixin.java @@ -6,9 +6,10 @@ */ package dev.isxander.controlify.mixins.feature.rumble.damage; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.BasicRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; +import dev.isxander.controlify.impl.Controlify; + +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.client.player.LocalPlayer; @@ -55,19 +56,17 @@ private void onHurtAnimation(ClientboundHurtAnimationPacket packet, CallbackInfo } @Unique private void playIncomingDamageRumble() { - ControlifyApi.get().playRumbleEffect( - RumbleSource.PLAYER, - BasicRumbleEffect.constant(0.8f, 0.5f, 5) + Controlify.instance().playRumbleEffect( + CUtil.rl("incoming_damage"), + RumbleEffect.finite().constant(0.8f, 0.5f, 5) ); } @Unique private void playOutgoingDamageRumble() { - ControlifyApi.get().playRumbleEffect( - RumbleSource.INTERACTION, - BasicRumbleEffect.seq( - BasicRumbleEffect.constant(0.18f, 0.4f, 1), - BasicRumbleEffect.constant(0.06f, 0.16f, 1) - ) + Controlify.instance().playRumbleEffect( + CUtil.rl("outgoing_damage"), + RumbleEffect.finite().constant(0.18f, 0.4f, 1) + .then(RumbleEffect.finite().constant(0.06f, 0.16f, 1)) ); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/explosion/ClientPacketListenerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/explosion/ClientPacketListenerMixin.java index a6c5b011f..6783ec96e 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/explosion/ClientPacketListenerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/explosion/ClientPacketListenerMixin.java @@ -6,10 +6,11 @@ */ package dev.isxander.controlify.mixins.feature.rumble.explosion; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.BasicRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; +import dev.isxander.controlify.impl.Controlify; + +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.network.protocol.game.ClientboundExplodePacket; @@ -31,19 +32,17 @@ private void onClientExplosion(ClientboundExplodePacket packet, CallbackInfo ci) return; } - ControlifyApi.get().playRumbleEffect( - RumbleSource.WORLD, - BasicRumbleEffect.seq( - BasicRumbleEffect.constant(magnitude, magnitude, 2), // initial boom - BasicRumbleEffect.byTime(t -> { + Controlify.instance().playRumbleEffect( + CUtil.rl("explosion"), + RumbleEffect.finite().constant(magnitude, magnitude, 2) // initial boom + .then(RumbleEffect.finite().byTime(t -> { float decay = 1f - t; decay *= decay; return new RumbleState( magnitude * 0.8f * decay, magnitude * 0.2f * decay ); - }, 8) // low-frequency aftershock - ) + }, 8)) // low-frequency aftershock ); } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/explosion/LightningBoltMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/explosion/LightningBoltMixin.java index a9aa814bb..ec9fb95bb 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/explosion/LightningBoltMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/explosion/LightningBoltMixin.java @@ -6,11 +6,12 @@ */ package dev.isxander.controlify.mixins.feature.rumble.explosion; +import dev.isxander.controlify.impl.Controlify; + import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.BasicRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.world.entity.LightningBolt; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -20,12 +21,10 @@ public class LightningBoltMixin { @ModifyExpressionValue(method = "tick", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/level/Level;isClientSide()Z")) private boolean onLightningStrike(boolean client) { if (client) { - ControlifyApi.get().playRumbleEffect( - RumbleSource.WORLD, - BasicRumbleEffect.seq( - BasicRumbleEffect.constant(1f, 0.2f, 6), // initial boom - BasicRumbleEffect.byTime(t -> new RumbleState(0f, 1 - t*0.2f), 10) // explosion - ) + Controlify.instance().playRumbleEffect( + CUtil.rl("lightning"), + RumbleEffect.finite().constant(1f, 0.2f, 6) // initial boom + .then(RumbleEffect.finite().byTime(t -> new RumbleState(0f, 1 - t*0.2f), 10)) // explosion ); } return client; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/fishing/FishingHookMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/fishing/FishingHookMixin.java index 5eb4de535..4ad145ebb 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/fishing/FishingHookMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/fishing/FishingHookMixin.java @@ -6,10 +6,12 @@ */ package dev.isxander.controlify.mixins.feature.rumble.fishing; +import dev.isxander.controlify.impl.Controlify; + import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.ContinuousRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.projectile.FishingHook; @@ -26,18 +28,17 @@ public class FishingHookMixin { @Shadow private boolean biting; @Unique private boolean isLocalPlayerHook; - @Unique private ContinuousRumbleEffect bitingRumble; + @Unique private RumbleEffectInstance bitingRumble; @ModifyExpressionValue(method = "onSyncedDataUpdated", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/syncher/SynchedEntityData;get(Lnet/minecraft/network/syncher/EntityDataAccessor;)Ljava/lang/Object;", ordinal = 1)) private Object onBitingStateUpdated(Object bitingObj) { var biting = (boolean) bitingObj; if (isLocalPlayerHook) { if (biting && !this.biting) { - this.bitingRumble = ContinuousRumbleEffect.builder() - .constant(0f, 0.05f) - .build(); + var effect = RumbleEffect.continuous() + .constant(0f, 0.05f); - ControlifyApi.get().playRumbleEffect(RumbleSource.INTERACTION, this.bitingRumble); + this.bitingRumble = Controlify.instance().playRumbleEffect(CUtil.rl("fishing_bite"), effect).orElse(null); } else if (!biting && this.biting) { stopBitingRumble(); } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/itembreak/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/itembreak/LocalPlayerMixin.java index dc44ed737..1a85196f0 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/itembreak/LocalPlayerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/itembreak/LocalPlayerMixin.java @@ -6,10 +6,11 @@ */ package dev.isxander.controlify.mixins.feature.rumble.itembreak; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.BasicRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; +import dev.isxander.controlify.impl.Controlify; + +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.item.ItemStack; import org.spongepowered.asm.mixin.Mixin; @@ -19,9 +20,9 @@ public class LocalPlayerMixin extends LivingEntityMixin { @Override protected void onBreakItemParticles(ItemStack stack, CallbackInfo ci) { - ControlifyApi.get().playRumbleEffect( - RumbleSource.PLAYER, - BasicRumbleEffect.byTick(tick -> new RumbleState(tick <= 4 ? 1f : 0f, 1f), 10) + Controlify.instance().playRumbleEffect( + CUtil.rl("item_break"), + RumbleEffect.finite().byTick(tick -> new RumbleState(tick <= 4 ? 1f : 0f, 1f), 10) ); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/levelevents/LevelEventHandlerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/levelevents/LevelEventHandlerMixin.java index e2f6a2f75..4bba5a410 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/levelevents/LevelEventHandlerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/levelevents/LevelEventHandlerMixin.java @@ -6,11 +6,12 @@ */ package dev.isxander.controlify.mixins.feature.rumble.levelevents; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.BasicRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; -import dev.isxander.controlify.utils.Easings; +import dev.isxander.controlify.impl.Controlify; + +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.utils.CUtil; +import dev.isxander.controlify.impl.utils.Easings; import net.minecraft.client.renderer.*; import net.minecraft.core.BlockPos; import net.minecraft.world.level.block.LevelEvent; @@ -24,12 +25,10 @@ public class LevelEventHandlerMixin { @Inject(method = "levelEvent", at = @At("HEAD")) private void onLevelEvent(int eventId, BlockPos pos, int data, CallbackInfo ci) { switch (eventId) { - case LevelEvent.SOUND_ANVIL_USED -> ControlifyApi.get().playRumbleEffect( - RumbleSource.GUI, - BasicRumbleEffect.seq( - BasicRumbleEffect.constant(1f, 0.5f, 2), - BasicRumbleEffect.empty(5) - ).repeat(3) + case LevelEvent.SOUND_ANVIL_USED -> Controlify.instance().playRumbleEffect( + CUtil.rl("anvil"), + RumbleEffect.finite().constant(1f, 0.5f, 2) + .then(RumbleEffect.finite().constant(0f, 0f, 5)).repeat(3) ); } } @@ -37,26 +36,22 @@ private void onLevelEvent(int eventId, BlockPos pos, int data, CallbackInfo ci) @Inject(method = "globalLevelEvent", at = @At("HEAD")) private void onGlobalLevelEvent(int eventId, BlockPos pos, int data, CallbackInfo ci) { switch (eventId) { - case LevelEvent.SOUND_DRAGON_DEATH -> ControlifyApi.get().playRumbleEffect( - RumbleSource.WORLD, - BasicRumbleEffect.seq( - BasicRumbleEffect.constant(1f, 1f, 194), - BasicRumbleEffect.byTime(t -> { + case LevelEvent.SOUND_DRAGON_DEATH -> Controlify.instance().playRumbleEffect( + CUtil.rl("dragon_death"), + RumbleEffect.finite().constant(1f, 1f, 194) + .then(RumbleEffect.finite().byTime(t -> { float easeOutQuad = (float) Easings.easeOutQuad(t); return new RumbleState(1 - easeOutQuad, 1 - easeOutQuad); - }, 63) - ).prioritised(10) + }, 63)) ); - case LevelEvent.SOUND_WITHER_BOSS_SPAWN -> ControlifyApi.get().playRumbleEffect( - RumbleSource.WORLD, - BasicRumbleEffect.seq( - BasicRumbleEffect.constant(1f, 1f, 9), - BasicRumbleEffect.constant(0.1f, 1f, 14), - BasicRumbleEffect.byTime(t -> { + case LevelEvent.SOUND_WITHER_BOSS_SPAWN -> Controlify.instance().playRumbleEffect( + CUtil.rl("wither_spawn"), + RumbleEffect.finite().constant(1f, 1f, 9) + .then(RumbleEffect.finite().constant(0.1f, 1f, 14)) + .then(RumbleEffect.finite().byTime(t -> { float easeOutQuad = 1 - (1 - t) * (1 - t); return new RumbleState(0f, 1 - easeOutQuad); - }, 56) - ).prioritised(10) + }, 56)) ); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/slowblock/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/slowblock/LocalPlayerMixin.java index cb290e01d..a557aac4d 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/slowblock/LocalPlayerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/slowblock/LocalPlayerMixin.java @@ -6,11 +6,13 @@ */ package dev.isxander.controlify.mixins.feature.rumble.slowblock; +import dev.isxander.controlify.impl.Controlify; + import com.mojang.authlib.GameProfile; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.ContinuousRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.player.ClientInput; @@ -29,7 +31,7 @@ public abstract class LocalPlayerMixin extends AbstractClientPlayer { @Shadow public ClientInput input; - @Unique private ContinuousRumbleEffect slowBlockRumble = null; + @Unique private RumbleEffectInstance slowBlockRumble = null; public LocalPlayerMixin(ClientLevel world, GameProfile profile) { super(world, profile); @@ -53,22 +55,21 @@ private void manageSlowBlockRumble(CallbackInfo ci) { } @Unique private void ensureRumbleStarted() { - if (slowBlockRumble == null || slowBlockRumble.isFinished()) { - slowBlockRumble = ContinuousRumbleEffect.builder() + if (slowBlockRumble == null || slowBlockRumble.hasFinished()) { + var effect = RumbleEffect.continuous() .byTick(i -> { float movementAmount = input.getMoveVector().length(); return new RumbleState(0.3f * movementAmount, 0.5f * movementAmount); }) - .timeout(100) - .build(); - ControlifyApi.get().playRumbleEffect(RumbleSource.PLAYER, slowBlockRumble); + .timeout(100); + slowBlockRumble = Controlify.instance().playRumbleEffect(CUtil.rl("slow_block"), effect).orElse(null); } else { - slowBlockRumble.heartbeat(); + slowBlockRumble.keepAlive(); } } @Unique private void ensureRumbleStopped() { - if (slowBlockRumble != null && !slowBlockRumble.isFinished()) { + if (slowBlockRumble != null && !slowBlockRumble.hasFinished()) { slowBlockRumble.stop(); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/ClientPacketListenerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/ClientPacketListenerMixin.java index 16b1821d4..1ece139df 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/ClientPacketListenerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/ClientPacketListenerMixin.java @@ -6,8 +6,8 @@ */ package dev.isxander.controlify.mixins.feature.rumble.useitem; -import dev.isxander.controlify.rumble.ContinuousRumbleEffect; -import dev.isxander.controlify.rumble.effects.UseItemEffectHolder; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.impl.controller.rumble.effects.UseItemEffectHolder; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.network.protocol.game.ClientboundRespawnPacket; @@ -21,7 +21,7 @@ public class ClientPacketListenerMixin { @Inject(method = "handleRespawn", at = @At(value = "FIELD", target = "Lnet/minecraft/client/Minecraft;player:Lnet/minecraft/client/player/LocalPlayer;", opcode = Opcodes.PUTFIELD)) private void clearUseItemRumble(ClientboundRespawnPacket packet, CallbackInfo ci) { - ContinuousRumbleEffect effect = ((UseItemEffectHolder) Minecraft.getInstance().player).controlify$getUseItemEffect(); + RumbleEffectInstance effect = ((UseItemEffectHolder) Minecraft.getInstance().player).controlify$getUseItemEffect(); if (effect != null) effect.stop(); } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LocalPlayerMixin.java index ab93c1261..70c8102a0 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LocalPlayerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/useitem/LocalPlayerMixin.java @@ -6,9 +6,13 @@ */ package dev.isxander.controlify.mixins.feature.rumble.useitem; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.rumble.*; -import dev.isxander.controlify.rumble.effects.UseItemEffectHolder; +import dev.isxander.controlify.impl.Controlify; + +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleEffectInstance; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.controller.rumble.effects.UseItemEffectHolder; +import dev.isxander.controlify.impl.utils.CUtil; import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.InteractionHand; import net.minecraft.world.item.BowItem; @@ -21,48 +25,42 @@ @Mixin(LocalPlayer.class) public abstract class LocalPlayerMixin extends LivingEntityMixin implements UseItemEffectHolder { - @Unique private ContinuousRumbleEffect useItemRumble; + @Unique private RumbleEffectInstance useItemRumble; @Override protected void onStartUsingItem(InteractionHand hand, CallbackInfo ci, ItemStack stack) { switch (stack.getUseAnimation()) { - case BOW -> startRumble(ContinuousRumbleEffect.builder() + case BOW -> startRumble(RumbleEffect.continuous() .byTick(tick -> new RumbleState( tick % 7 <= 3 && tick > BowItem.MAX_DRAW_DURATION ? 0.1f : 0f, BowItem.getPowerForTime(tick) - )) - .build()); + ))); case CROSSBOW -> { int chargeDuration = CrossbowItem.getChargeDuration( stack, (LocalPlayer) (Object) this ); - startRumble(ContinuousRumbleEffect.builder() + startRumble(RumbleEffect.continuous() .byTick(tick -> new RumbleState( 0f, (float) tick / chargeDuration )) - .timeout(chargeDuration) - .build()); + .timeout(chargeDuration)); } - case BLOCK, SPYGLASS -> startRumble(ContinuousRumbleEffect.builder() + case BLOCK, SPYGLASS -> startRumble(RumbleEffect.continuous() .byTick(tick -> new RumbleState( 0f, tick % 4 / 4f * 0.12f + 0.05f - )) - .build()); - case EAT, DRINK -> startRumble(ContinuousRumbleEffect.builder() - .constant(0.1f, 0.2f) - .build()); - case TOOT_HORN -> startRumble(ContinuousRumbleEffect.builder() + ))); + case EAT, DRINK -> startRumble(RumbleEffect.continuous() + .constant(0.1f, 0.2f)); + case TOOT_HORN -> startRumble(RumbleEffect.continuous() .byTick(tick -> new RumbleState( Math.min(1f, tick / 10f), 0.25f - )) - .build()); - case SPEAR -> startRumble(ContinuousRumbleEffect.builder() - .constant(0.3f, 0.3f) - .build()); + ))); + case SPEAR -> startRumble(RumbleEffect.continuous() + .constant(0.3f, 0.3f)); } } @@ -79,13 +77,13 @@ protected void onStopUsingItem(CallbackInfo ci) { } } - @Unique private void startRumble(ContinuousRumbleEffect effect) { - ControlifyApi.get().playRumbleEffect(RumbleSource.INTERACTION, effect); - this.useItemRumble = effect; + @Unique private void startRumble(RumbleEffect effect) { + if (useItemRumble != null) useItemRumble.stop(); + this.useItemRumble = Controlify.instance().playRumbleEffect(CUtil.rl("use_item"), effect).orElse(null); } @Override - public @Nullable ContinuousRumbleEffect controlify$getUseItemEffect() { + public @Nullable RumbleEffectInstance controlify$getUseItemEffect() { return useItemRumble; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/waterland/LocalPlayerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/waterland/LocalPlayerMixin.java index dc2d5c5b9..cef69f462 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/rumble/waterland/LocalPlayerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/rumble/waterland/LocalPlayerMixin.java @@ -6,11 +6,13 @@ */ package dev.isxander.controlify.mixins.feature.rumble.waterland; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.rumble.BasicRumbleEffect; -import dev.isxander.controlify.rumble.RumbleSource; -import dev.isxander.controlify.rumble.RumbleState; +import dev.isxander.controlify.impl.Controlify; + +import dev.isxander.controlify.api.controller.rumble.RumbleEffect; +import dev.isxander.controlify.api.controller.rumble.RumbleState; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.utils.CUtil; +import java.util.Objects; import net.minecraft.client.player.LocalPlayer; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; @@ -20,8 +22,6 @@ import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -import java.util.Objects; - @Mixin(LocalPlayer.class) public abstract class LocalPlayerMixin extends PlayerMixin { protected LocalPlayerMixin(EntityType entityType, Level level) { @@ -34,7 +34,7 @@ protected void splashRumble(CallbackInfo ci) { return; } - ControlifyApi.get().getCurrentController().flatMap(ControllerEntity::rumble).ifPresent(rumble -> { + Controlify.instance().getCurrentController().flatMap(ControllerImpl::rumble).ifPresent(rumble -> { Entity entity = Objects.requireNonNullElse(this.getControllingPassenger(), this); float f = entity == this ? 0.2F : 0.9F; Vec3 vec3 = entity.getDeltaMovement(); @@ -42,9 +42,9 @@ protected void splashRumble(CallbackInfo ci) { if (impactForce >= 0.05f) { float multiplier = Math.min(1, impactForce / 0.5f); - rumble.rumbleManager().play( - RumbleSource.PLAYER, - BasicRumbleEffect.byTime( + rumble.playEffect( + CUtil.rl("water_land"), + RumbleEffect.finite().byTime( t -> new RumbleState(multiplier * (1 - t), multiplier * 0.5f), impactForce < 0.25f ? 10 : 20 ) diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ContainerEventHandlerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ContainerEventHandlerMixin.java deleted file mode 100644 index 261c441a6..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ContainerEventHandlerMixin.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.screenop; - -import dev.isxander.controlify.screenop.CustomFocus; -import net.minecraft.client.gui.components.events.ContainerEventHandler; -import net.minecraft.client.gui.components.events.GuiEventListener; -import org.jetbrains.annotations.Nullable; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; - -@Mixin(ContainerEventHandler.class) -public interface ContainerEventHandlerMixin extends CustomFocus { - @Shadow - @Nullable GuiEventListener getFocused(); - - @Override - default GuiEventListener getCustomFocus() { - return this.getFocused(); - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/GuiMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/GuiMixin.java index 0ad891a81..f79608d74 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/GuiMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/GuiMixin.java @@ -8,27 +8,19 @@ import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; import com.llamalad7.mixinextras.sugar.Local; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.keyboard.KeyboardOverlayScreen; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardOverlayScreen; import net.minecraft.client.gui.screens.Screen; 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( //? if >=26.2 { net.minecraft.client.gui.Gui.class //?} else { - /*net.minecraft.client.Minecraft.class - *///?} + //net.minecraft.client.Minecraft.class + //?} ) public class GuiMixin { - @Inject(method = "setScreen", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;updateTitle()V")) - private void changeScreen(Screen screen, CallbackInfo ci) { - ComponentProcessorProvider.REGISTRY.clearCache(); - } - @WrapWithCondition(method = "setScreen", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/screens/Screen;removed()V")) private boolean preventRemovingOldScreen(Screen oldScreen, @Local(argsOnly = true, name = "screen") Screen screen) { return !(screen instanceof KeyboardOverlayScreen); diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ScreenMixin.java index 48bc4eb46..5c1090052 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/ScreenMixin.java @@ -7,49 +7,17 @@ package dev.isxander.controlify.mixins.feature.screenop; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.screenop.ScreenProcessorFactory; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.ScreenProcessor; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import dev.isxander.controlify.impl.screenop.ScreenBehaviourRegistryImpl; import net.minecraft.client.gui.screens.Screen; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; -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(Screen.class) -public abstract class ScreenMixin implements ScreenProcessorProvider { - @Shadow - public abstract void clearFocus(); - - @Unique private final ScreenProcessor controlify$processor = ScreenProcessorFactory.createForScreen((Screen) (Object) this); - - @Override - public ScreenProcessor screenProcessor() { - return controlify$processor; - } - - @Inject( - method = "init(II)V", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/client/gui/screens/Screen;init()V", - shift = At.Shift.AFTER - ) - ) - private void onScreenInitialInit(CallbackInfo ci) { - // cannot use screenProcessor() because it may be overriden by registry - ScreenProcessorProvider.provide((Screen) (Object) this).onWidgetRebuild(); - } - - @Inject(method = "rebuildWidgets", at = @At("RETURN")) - private void onScreenInit(CallbackInfo ci) { - // cannot use screenProcessor() because it may be overriden by registry - ScreenProcessorProvider.provide((Screen) (Object) this).onWidgetRebuild(); - } - +public abstract class ScreenMixin { // allow controller input to set initial focus @ModifyExpressionValue( method = "setInitialFocus()V", @@ -59,14 +27,18 @@ private void onScreenInit(CallbackInfo ci) { ) ) private boolean shouldSetInitialFocus(boolean isKeyboard) { + if (ScreenBehaviourRegistryImpl.INSTANCE.isReady() + && ScreenContextImpl.get((Screen) (Object) this).virtualMouse().isRequired()) { + return false; + } return isKeyboard || Controlify.instance().currentInputMode().isController(); } - // setInitialFocus just sends a tab focus event, - // so if this is ever called once per screen open, it would have caused - // tab to happen twice and the wrong widget to be focused - @Inject(method = "setInitialFocus()V", at = @At("HEAD")) - private void clearFocusBeforeTabbing(CallbackInfo ci) { - this.clearFocus(); + @Inject(method = "setInitialFocus()V", at = @At("HEAD"), cancellable = true) + private void preserveCustomInitialFocus(CallbackInfo ci) { + if (Controlify.instance().currentInputMode().isController() + && ((Screen) (Object) this).getFocused() != null) { + ci.cancel(); + } } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/bundle/BundleMouseActionsMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/bundle/BundleMouseActionsMixin.java index a9a7f4038..f8fb0d9fc 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/bundle/BundleMouseActionsMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/bundle/BundleMouseActionsMixin.java @@ -6,9 +6,9 @@ */ package dev.isxander.controlify.mixins.feature.screenop.impl.bundle; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.compat.vanilla.BundleItemSlotControllerAction; -import dev.isxander.controlify.screenop.compat.vanilla.ItemSlotControllerAction; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.BundleItemSlotControllerAction; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.ItemSlotControllerAction; import net.minecraft.client.gui.BundleMouseActions; import net.minecraft.world.item.ItemStack; import org.spongepowered.asm.mixin.Mixin; @@ -20,7 +20,7 @@ public abstract class BundleMouseActionsMixin implements ItemSlotControllerActio protected abstract void toggleSelectedBundleItem(ItemStack bundleItem, int slotIndex, int selectedItem); @Override - public boolean controlify$onControllerInput(ItemStack stack, int hoveredSlotIndex, ControllerEntity controller) { + public boolean controlify$onControllerInput(ItemStack stack, int hoveredSlotIndex, ControllerImpl controller) { return BundleItemSlotControllerAction.onControllerInput(stack, hoveredSlotIndex, controller, this::toggleSelectedBundleItem); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/ChatComponentMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/ChatComponentMixin.java index 14f3a0e88..a719f48fc 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/ChatComponentMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/ChatComponentMixin.java @@ -9,8 +9,8 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.screenop.keyboard.ChatKeyboardDucky; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.gui.keyboard.ChatKeyboardDucky; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.gui.components.ChatComponent; import net.minecraft.client.gui.screens.ChatScreen; import org.spongepowered.asm.mixin.Mixin; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/ChatScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/ChatScreenMixin.java index 6e5637684..e61de46b2 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/ChatScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/ChatScreenMixin.java @@ -10,10 +10,12 @@ import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.ChatScreenProcessor; -import dev.isxander.controlify.screenop.keyboard.*; +import dev.isxander.controlify.api.gui.keyboard.KeyboardLayouts; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.gui.keyboard.ChatKeyboardDucky; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardWidget; +import dev.isxander.controlify.impl.gui.keyboard.MixinInputTarget; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.ChatScreenBehaviour; import net.minecraft.client.gui.components.CommandSuggestions; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.components.events.GuiEventListener; @@ -34,19 +36,15 @@ import java.util.function.Predicate; @Mixin(ChatScreen.class) -public abstract class ChatScreenMixin extends Screen implements ScreenProcessorProvider, MixinInputTarget, ChatKeyboardDucky { +public abstract class ChatScreenMixin extends Screen implements ChatScreenBehaviour.Access, MixinInputTarget, ChatKeyboardDucky { - @Shadow protected EditBox input; - @Shadow private CommandSuggestions commandSuggestions; + @Shadow + protected EditBox input; + @Shadow + private CommandSuggestions commandSuggestions; @Unique private KeyboardWidget keyboard; @Unique private float shiftChatAmt = 0f; - @Unique private final ChatScreenProcessor screenProcessor = new ChatScreenProcessor( - (ChatScreen) (Object) this, - () -> this.input, - () -> this.keyboard, - () -> (ChatScreenProcessor.CmdSuggestionsController) this.commandSuggestions - ); protected ChatScreenMixin(Component title) { super(title); @@ -56,15 +54,15 @@ protected ChatScreenMixin(Component title) { private void addKeyboard(CallbackInfo ci) { this.shiftChatAmt = 0f; - ControlifyApi.get().getCurrentController().ifPresent(c -> { + Controlify.instance().getCurrentController().ifPresent(c -> { // if the keyboard is already present, re-add it even if we're in kb/m mode since // setting fullscreen will turn it to that mode - if (!ControlifyApi.get().currentInputMode().isController() && this.keyboard == null) return; + if (!Controlify.instance().currentInputMode().isController() && this.keyboard == null) return; if (!c.settings().generic.keyboard.showOnScreenKeyboard) return; this.shiftChatAmt = 0.5f; int keyboardHeight = (int) (this.height * this.shiftChatAmt); - this.addRenderableWidget(this.keyboard = new KeyboardWidget(0, this.height - keyboardHeight, this.width, keyboardHeight, KeyboardLayouts.full(), this)); + this.addRenderableWidget(this.keyboard = new KeyboardWidget(0, this.height - keyboardHeight, this.width, keyboardHeight, Controlify.instance().keyboardLayoutManager().getLayout(KeyboardLayouts.FULL), this)); }); } @@ -170,8 +168,17 @@ private int modifyMaxSuggestionCount(int count) { } @Override - public ChatScreenProcessor screenProcessor() { - return this.screenProcessor; + public EditBox controlify$getInput() { + return this.input; } + @Override + public KeyboardWidget controlify$getKeyboard() { + return this.keyboard; + } + + @Override + public ChatScreenBehaviour.CmdSuggestionsController controlify$getSuggestionsController() { + return (ChatScreenBehaviour.CmdSuggestionsController) this.commandSuggestions; + } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/CommandSuggestionsMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/CommandSuggestionsMixin.java index 77189c7e5..d3e64154c 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/CommandSuggestionsMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/chat/CommandSuggestionsMixin.java @@ -7,9 +7,9 @@ package dev.isxander.controlify.mixins.feature.screenop.impl.chat; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.screenop.keyboard.ChatKeyboardDucky; -import dev.isxander.controlify.screenop.compat.vanilla.ChatScreenProcessor; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.ChatScreenBehaviour; +import dev.isxander.controlify.impl.utils.MinecraftUtil; +import dev.isxander.controlify.impl.gui.keyboard.ChatKeyboardDucky; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.components.CommandSuggestions; import net.minecraft.client.gui.screens.ChatScreen; @@ -21,7 +21,7 @@ import org.spongepowered.asm.mixin.injection.At; @Mixin(CommandSuggestions.class) -public class CommandSuggestionsMixin implements ChatScreenProcessor.CmdSuggestionsController { +public class CommandSuggestionsMixin implements ChatScreenBehaviour.CmdSuggestionsController { @Shadow @Final Minecraft minecraft; @@ -34,8 +34,8 @@ public class CommandSuggestionsMixin implements ChatScreenProcessor.CmdSuggestio //? if >=26.2 { "extractUsage", //?} else { - /*"renderUsage", - *///?} + //"renderUsage", + //?} "showSuggestions" }, at = @At( diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/AbstractContainerScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/AbstractContainerScreenMixin.java index b90198bfd..87648f22c 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/AbstractContainerScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/AbstractContainerScreenMixin.java @@ -7,78 +7,51 @@ package dev.isxander.controlify.mixins.feature.screenop.impl.container; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import com.llamalad7.mixinextras.sugar.Share; -import com.llamalad7.mixinextras.sugar.ref.LocalRef; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.AbstractContainerScreenProcessor; -import dev.isxander.controlify.screenop.compat.vanilla.ItemSlotControllerAction; -import net.minecraft.client.gui.GuiGraphicsExtractor; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.ContainerScreenBehaviour; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.ItemSlotControllerAction; import net.minecraft.client.gui.ItemSlotMouseAction; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.world.inventory.ContainerInput; import net.minecraft.world.inventory.Slot; import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; -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; import java.util.List; - @Mixin(AbstractContainerScreen.class) -public abstract class AbstractContainerScreenMixin implements ScreenProcessorProvider { - @Shadow @Nullable protected Slot hoveredSlot; +public abstract class AbstractContainerScreenMixin implements ContainerScreenBehaviour.Access { + @Shadow + @Nullable protected Slot hoveredSlot; - @Shadow protected abstract void slotClicked( + @Shadow + protected abstract void slotClicked( Slot slot, int slotId, int buttonNum, ContainerInput containerInput ); - @Shadow @Final private List itemSlotMouseActions; - - @Unique protected AbstractContainerScreenProcessor screenProcessor = new AbstractContainerScreenProcessor<>( - (AbstractContainerScreen) (Object) this, - () -> hoveredSlot, - this::slotClicked, - this::handleControllerItemSlotActions - ); + @Shadow + @Final + private List itemSlotMouseActions; @Override - public ScreenProcessor screenProcessor() { - return screenProcessor; + public void controlify$clickSlot(@Nullable Slot slot, int slotId, int button, @NonNull ContainerInput input) { + this.slotClicked(slot, slotId, button, input); } - @Inject(method = "extractRenderState", at = @At("HEAD")) - private void setPrevSlotShare(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a, CallbackInfo ci, @Share("prevSlot") LocalRef prevSlot) { - prevSlot.set(this.hoveredSlot); - } - - @Inject(method = "extractRenderState", at = @At("RETURN")) - private void triggerSlotHovered(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a, CallbackInfo ci, @Share("prevSlot") LocalRef prevSlot) { - @Nullable Slot oldSlot = prevSlot.get(); - @Nullable Slot newSlot = this.hoveredSlot; - - if (oldSlot != null || newSlot != null) { - if (oldSlot == null || (newSlot != null && newSlot.index != oldSlot.index)) { - screenProcessor.onHoveredSlotChanged(newSlot, oldSlot); - } - } - } - - @Unique protected boolean handleControllerItemSlotActions(ControllerEntity controller) { + @Override + public boolean controlify$handleItemSlotActions(@NonNull ControllerImpl controller) { for (ItemSlotMouseAction itemSlotMouseAction : this.itemSlotMouseActions) { if (itemSlotMouseAction instanceof ItemSlotControllerAction itemSlotControllerAction - && itemSlotMouseAction.matches(this.hoveredSlot) - && itemSlotControllerAction.controlify$onControllerInput(this.hoveredSlot.getItem(), this.hoveredSlot.index, controller)) { + && itemSlotMouseAction.matches(this.hoveredSlot) + && itemSlotControllerAction.controlify$onControllerInput(this.hoveredSlot.getItem(), this.hoveredSlot.index, controller)) { return true; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/AbstractRecipeBookScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/AbstractRecipeBookScreenMixin.java index f8fcdadfa..a4fe63280 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/AbstractRecipeBookScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/AbstractRecipeBookScreenMixin.java @@ -6,30 +6,16 @@ */ package dev.isxander.controlify.mixins.feature.screenop.impl.container; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.RecipeBookScreenProcessor; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.RecipeBookScreenBehaviour; import net.minecraft.client.gui.screens.inventory.*; import net.minecraft.client.gui.screens.recipebook.RecipeBookComponent; -import net.minecraft.client.gui.screens.recipebook.RecipeUpdateListener; import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; @Mixin(AbstractRecipeBookScreen.class) public abstract class AbstractRecipeBookScreenMixin - extends AbstractContainerScreenMixin - implements ScreenProcessorProvider, RecipeUpdateListener, RecipeBookScreenProcessor.RecipeBookScreenAccessor { - - @Unique private final RecipeBookScreenProcessor processor = - new RecipeBookScreenProcessor<>( - (AbstractRecipeBookScreen) (Object) this, - this, - () -> hoveredSlot, - this::slotClicked, - this::handleControllerItemSlotActions - ); + implements RecipeBookScreenBehaviour.RecipeBookScreenAccessor { @Shadow @Final @@ -40,8 +26,4 @@ public abstract class AbstractRecipeBookScreenMixin return this.recipeBookComponent; } - @Override - public ScreenProcessor screenProcessor() { - return processor; - } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/CreativeModeInventoryScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/CreativeModeInventoryScreenMixin.java index 5e1925e3d..2ab2b80ac 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/CreativeModeInventoryScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/container/CreativeModeInventoryScreenMixin.java @@ -6,58 +6,17 @@ */ package dev.isxander.controlify.mixins.feature.screenop.impl.container; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.CreativeModeInventoryScreenProcessor; -import dev.isxander.controlify.screenop.compat.vanilla.EditBoxComponentProcessor; -import dev.isxander.controlify.screenop.keyboard.InputTarget; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; -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; +import org.spongepowered.asm.mixin.gen.Accessor; +import org.spongepowered.asm.mixin.gen.Invoker; @Mixin(CreativeModeInventoryScreen.class) -public abstract class CreativeModeInventoryScreenMixin extends AbstractContainerScreenMixin implements ScreenProcessorProvider { - @Shadow - private EditBox searchBox; +public interface CreativeModeInventoryScreenMixin { + @Accessor("searchBox") + EditBox controlify$getSearchBox(); - @Shadow - protected abstract void refreshSearchResults(); - - @Unique protected CreativeModeInventoryScreenProcessor screenProcessor = new CreativeModeInventoryScreenProcessor( - (CreativeModeInventoryScreen) (Object) this, - () -> hoveredSlot, - this::slotClicked, - this::handleControllerItemSlotActions - ); - - @Inject(method = "init", at = @At("RETURN")) - private void fixSearchUpdate(CallbackInfo ci) { - if (this.searchBox != null) { - var processor = (EditBoxComponentProcessor) ComponentProcessorProvider.provide(this.searchBox); - processor.setInputTarget(new InputTarget.Delegated(new EditBoxComponentProcessor.EditBoxInputTarget(this.searchBox)) { - @Override - public boolean acceptChar(char ch, int modifiers) { - CreativeModeInventoryScreenMixin.this.refreshSearchResults(); - return super.acceptChar(ch, modifiers); - } - - @Override - public boolean acceptKeyCode(int keycode, int scancode, int modifiers) { - CreativeModeInventoryScreenMixin.this.refreshSearchResults(); - return super.acceptKeyCode(keycode, scancode, modifiers); - } - }); - } - } - - @Override - public ScreenProcessor screenProcessor() { - return screenProcessor; - } + @Invoker("refreshSearchResults") + void controlify$refreshSearchResults(); } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractButtonMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractButtonMixin.java deleted file mode 100644 index 146ecfb6e..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractButtonMixin.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.screenop.impl.elements; - -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.AbstractButtonComponentProcessor; -import net.minecraft.client.gui.components.AbstractButton; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(AbstractButton.class) -public class AbstractButtonMixin implements ComponentProcessorProvider { - @Unique private final AbstractButtonComponentProcessor controlify$processor - = new AbstractButtonComponentProcessor((AbstractButton) (Object) this); - - @Override - public ComponentProcessor componentProcessor() { - return controlify$processor; - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractContainerEventHandlerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractContainerEventHandlerMixin.java deleted file mode 100644 index 58d629604..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractContainerEventHandlerMixin.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.screenop.impl.elements; - -import dev.isxander.controlify.screenop.CustomFocus; -import net.minecraft.client.gui.components.events.AbstractContainerEventHandler; -import net.minecraft.client.gui.components.events.GuiEventListener; -import org.jetbrains.annotations.Nullable; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; - -@Mixin(AbstractContainerEventHandler.class) -public abstract class AbstractContainerEventHandlerMixin implements CustomFocus { - @Shadow public abstract @Nullable GuiEventListener getFocused(); - - @Override - public GuiEventListener getCustomFocus() { - return getFocused(); - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractSliderButtonMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractSliderButtonMixin.java deleted file mode 100644 index 9404aa0c5..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractSliderButtonMixin.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.screenop.impl.elements; - -import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.SliderComponentProcessor; -import net.minecraft.client.InputType; -import net.minecraft.client.gui.components.AbstractSliderButton; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; -import org.spongepowered.asm.mixin.injection.At; - -/** - * Mixin to insert a custom {@link ComponentProcessor} into slider to support left/right movement without navigating to next component. - */ -@Mixin(AbstractSliderButton.class) -public class AbstractSliderButtonMixin implements ComponentProcessorProvider { - @Shadow private boolean canChangeValue; - - @Unique private final SliderComponentProcessor controlify$processor = new SliderComponentProcessor( - (AbstractSliderButton) (Object) this, - () -> this.canChangeValue, - val -> this.canChangeValue = val - ); - - @ModifyExpressionValue(method = "setFocused", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;getLastInputType()Lnet/minecraft/client/InputType;")) - private InputType shouldChangeValue(InputType type) { - if (Controlify.instance().currentInputMode().isController()) - return InputType.NONE; // none doesn't pass condition - return type; - } - - @Override - public ComponentProcessor componentProcessor() { - return controlify$processor; - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractWidgetMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractWidgetMixin.java index 2c3503a84..8b0558853 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractWidgetMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/AbstractWidgetMixin.java @@ -7,10 +7,10 @@ package dev.isxander.controlify.mixins.feature.screenop.impl.elements; import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.controller.ControllerImpl; +import dev.isxander.controlify.impl.screenop.ScreenRuntime; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.input.MouseButtonEvent; import org.spongepowered.asm.mixin.Mixin; @@ -29,11 +29,11 @@ private boolean openKeyboardInVmouseMode(AbstractWidget instance, MouseButtonEve assert MinecraftUtil.getScreen() != null; Controlify controlify = Controlify.instance(); - ControllerEntity controller = controlify.getCurrentController().orElse(null); + ControllerImpl controller = controlify.getCurrentController().orElse(null); - if (controller != null && controlify.virtualMouseHandler().isVirtualMouseEnabled()) { - var screenProcessor = ScreenProcessorProvider.provide(MinecraftUtil.getScreen()); - return !screenProcessor.tryOpenKeyboard(controller, (AbstractWidget) (Object) this); + if (controller != null && ScreenRuntime.virtualMouse().map(mouse -> mouse.isEnabled()).orElse(false)) { + var context = ScreenRuntime.current(); + return context == null || !context.tryOpenKeyboard(controller, (AbstractWidget) (Object) this); } return true; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/ContainerObjectSelectionListEntryMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/ContainerObjectSelectionListEntryMixin.java deleted file mode 100644 index 21de78b44..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/ContainerObjectSelectionListEntryMixin.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.screenop.impl.elements; - -import dev.isxander.controlify.screenop.CustomFocus; -import net.minecraft.client.gui.components.ContainerObjectSelectionList; -import net.minecraft.client.gui.components.events.GuiEventListener; -import org.jetbrains.annotations.Nullable; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; - -@Mixin(ContainerObjectSelectionList.Entry.class) -public abstract class ContainerObjectSelectionListEntryMixin implements CustomFocus { - @Shadow public abstract @Nullable GuiEventListener getFocused(); - - @Override - public GuiEventListener getCustomFocus() { - return getFocused(); - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/EditBoxMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/EditBoxMixin.java index 47c767f6c..912caf0de 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/EditBoxMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/elements/EditBoxMixin.java @@ -10,15 +10,13 @@ import com.llamalad7.mixinextras.sugar.Local; import com.llamalad7.mixinextras.sugar.Share; import com.llamalad7.mixinextras.sugar.ref.LocalBooleanRef; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.EditBoxComponentProcessor; -import dev.isxander.controlify.screenop.keyboard.CommonKeyboardHints; -import dev.isxander.controlify.screenop.keyboard.ComponentKeyboardBehaviour; -import dev.isxander.controlify.screenop.keyboard.KeyboardOverlayScreen; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.api.font.ActionGlyphs; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.gui.keyboard.CommonKeyboardHints; +import dev.isxander.controlify.api.screenop.ElementKeyboardBehaviour; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardOverlayScreen; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; @@ -34,21 +32,19 @@ import org.spongepowered.asm.mixin.injection.ModifyVariable; @Mixin(EditBox.class) -public abstract class EditBoxMixin extends AbstractWidget implements ComponentProcessorProvider { +public abstract class EditBoxMixin extends AbstractWidget { - @Shadow private @Nullable Component hint; - @Shadow private @Nullable String suggestion; - @Shadow @Final private Font font; + @Shadow + private @Nullable Component hint; + @Shadow + private @Nullable String suggestion; + @Shadow + @Final + private Font font; @Shadow public abstract boolean isBordered(); - @Unique private final EditBoxComponentProcessor processor = new EditBoxComponentProcessor( - (EditBox) (Object) this, - Minecraft.getInstance().getWindow().getGuiScaledWidth(), - Minecraft.getInstance().getWindow().getGuiScaledHeight() - ); - public EditBoxMixin(int x, int y, int width, int height, Component message) { super(x, y, width, height, message); } @@ -66,13 +62,13 @@ private String renderHintText( ) { renderHint.set(false); - ControlifyApi.get().getCurrentController().ifPresent(controller -> { + Controlify.instance().getCurrentController().ifPresent(controller -> { if (this.isFocused() && controller.settings().generic.keyboard.showOnScreenKeyboard && controller.settings().generic.guide.showScreenGuides - && ControlifyApi.get().currentInputMode().isController() + && Controlify.instance().currentInputMode().isController() && !(MinecraftUtil.getScreen() instanceof KeyboardOverlayScreen) - && processor.getKeyboardBehaviour() instanceof ComponentKeyboardBehaviour.Handled + && controlify$hasKeyboardBehaviour() ) { int textX = this.getX() + (this.isBordered() ? 2 : 0) + 2; int textY = this.getY() + (this.isBordered() ? 2 : 0) + 4; @@ -96,7 +92,7 @@ private String renderHintText( pose.popMatrix(); } else { - var component = ControlifyBindings.GUI_PRESS.inputGlyph(); + var component = ActionGlyphs.action(ControlifyActions.GUI_PRESS.actionId()); int width = font.width(component); graphics.text( @@ -117,8 +113,14 @@ private boolean preventShowingCursor(boolean cursorOnScreen, @Share("renderHint" return cursorOnScreen && !renderHint.get(); } - @Override - public ComponentProcessor componentProcessor() { - return processor; + @Unique private boolean controlify$hasKeyboardBehaviour() { + var screen = MinecraftUtil.getScreen(); + if (screen == null) { + return false; + } + var context = dev.isxander.controlify.impl.screenop.ScreenContextImpl.get(screen); + return context.elementBehaviour((EditBox) (Object) this).keyboardBehaviour(context) + .orElse(null) instanceof ElementKeyboardBehaviour.Handled; } + } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/CreateWorldScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/CreateWorldScreenMixin.java index 3e437fc10..f9b4fc348 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/CreateWorldScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/CreateWorldScreenMixin.java @@ -7,26 +7,22 @@ package dev.isxander.controlify.mixins.feature.screenop.impl.outofgame; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.CreateWorldScreenProcessor; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.api.screenop.*; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.CreateWorldScreenBehaviour; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.worldselection.CreateWorldScreen; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Slice; @Mixin(CreateWorldScreen.class) -public abstract class CreateWorldScreenMixin implements ScreenProcessorProvider { - @Shadow protected abstract void onCreate(); - - @Unique private final ScreenProcessor processor - = new CreateWorldScreenProcessor((CreateWorldScreen) (Object) this, this::onCreate); +public abstract class CreateWorldScreenMixin implements CreateWorldScreenBehaviour.Access { + @Shadow + protected abstract void onCreate(); @ModifyExpressionValue( method = "init()V", @@ -43,9 +39,9 @@ public abstract class CreateWorldScreenMixin implements ScreenProcessorProvider ) ) private Button modifyCreateButton(Button button) { - ButtonGuideApi.addGuideToButton( + ScreenContextImpl.get((net.minecraft.client.gui.screens.Screen) (Object) this).buttonGuide().addGuideToButton( button, - ControlifyBindings.GUI_ABSTRACT_ACTION_1, + ControlifyActions.GUI_ABSTRACT_ACTION_1, ButtonGuidePredicate.always() ); return button; @@ -66,16 +62,16 @@ private Button modifyCreateButton(Button button) { ) ) private Button modifyCancelButton(Button button) { - ButtonGuideApi.addGuideToButton( + ScreenContextImpl.get((net.minecraft.client.gui.screens.Screen) (Object) this).buttonGuide().addGuideToButton( button, - ControlifyBindings.GUI_BACK, + ControlifyActions.GUI_BACK, ButtonGuidePredicate.always() ); return button; } @Override - public ScreenProcessor screenProcessor() { - return processor; + public void controlify$createWorld() { + this.onCreate(); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/DirectJoinServerScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/DirectJoinServerScreenMixin.java index 4db72e6fa..8dcdcad76 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/DirectJoinServerScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/DirectJoinServerScreenMixin.java @@ -9,33 +9,23 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.AddServerLikeScreenProcessor; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.ServerAddressScreenAccess; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.DirectJoinServerScreen; -import net.minecraft.client.gui.screens.Screen; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; @Mixin(DirectJoinServerScreen.class) -public class DirectJoinServerScreenMixin implements ScreenProcessorProvider { +public class DirectJoinServerScreenMixin implements ServerAddressScreenAccess { @Shadow private EditBox ipEdit; @Shadow private Button selectButton; @Unique private Button cancelButton; - @Unique private final AddServerLikeScreenProcessor screenProcessor = new AddServerLikeScreenProcessor( - (Screen) (Object) this, - () -> this.ipEdit, - () -> this.selectButton, - () -> this.cancelButton - ); - @Definition(id = "builder", method = "Lnet/minecraft/client/gui/components/Button;builder(Lnet/minecraft/network/chat/Component;Lnet/minecraft/client/gui/components/Button$OnPress;)Lnet/minecraft/client/gui/components/Button$Builder;") @Definition(id = "GUI_CANCEL", field = "Lnet/minecraft/network/chat/CommonComponents;GUI_CANCEL:Lnet/minecraft/network/chat/Component;") @Definition(id = "build", method = "Lnet/minecraft/client/gui/components/Button$Builder;build()Lnet/minecraft/client/gui/components/Button;") @@ -46,7 +36,17 @@ private Button captureCancelButton(Button button) { } @Override - public ScreenProcessor screenProcessor() { - return this.screenProcessor; + public EditBox controlify$getAddressField() { + return this.ipEdit; + } + + @Override + public Button controlify$getDoneButton() { + return this.selectButton; + } + + @Override + public Button controlify$getBackButton() { + return this.cancelButton; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/JoinMultiplayerScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/JoinMultiplayerScreenMixin.java index af33f0054..8d2e23981 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/JoinMultiplayerScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/JoinMultiplayerScreenMixin.java @@ -9,9 +9,7 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.JoinMultiplayerScreenProcessor; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.JoinMultiplayerScreenBehaviour; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.multiplayer.JoinMultiplayerScreen; import net.minecraft.client.gui.screens.multiplayer.ServerSelectionList; @@ -21,21 +19,14 @@ import org.spongepowered.asm.mixin.injection.At; @Mixin(JoinMultiplayerScreen.class) -public class JoinMultiplayerScreenMixin implements ScreenProcessorProvider { - @Shadow protected ServerSelectionList serverSelectionList; +public class JoinMultiplayerScreenMixin implements JoinMultiplayerScreenBehaviour.Access { + @Shadow + protected ServerSelectionList serverSelectionList; @Unique private Button backButton; @Unique private Button directConnectButton; @Unique private Button addServerButton; - @Unique private final JoinMultiplayerScreenProcessor processor = new JoinMultiplayerScreenProcessor( - (JoinMultiplayerScreen) (Object) this, - () -> this.serverSelectionList, - () -> this.backButton, - () -> this.directConnectButton, - () -> this.addServerButton - ); - @Definition(id = "builder", method = "Lnet/minecraft/client/gui/components/Button;builder(Lnet/minecraft/network/chat/Component;Lnet/minecraft/client/gui/components/Button$OnPress;)Lnet/minecraft/client/gui/components/Button$Builder;") @Definition(id = "build", method = "Lnet/minecraft/client/gui/components/Button$Builder;build()Lnet/minecraft/client/gui/components/Button;") @Definition(id = "GUI_BACK", field = "Lnet/minecraft/network/chat/CommonComponents;GUI_BACK:Lnet/minecraft/network/chat/Component;") @@ -64,7 +55,22 @@ private Button captureAddServerButton(Button button) { } @Override - public ScreenProcessor screenProcessor() { - return processor; + public ServerSelectionList controlify$getServerList() { + return this.serverSelectionList; + } + + @Override + public Button controlify$getBackButton() { + return this.backButton; + } + + @Override + public Button controlify$getDirectConnectButton() { + return this.directConnectButton; + } + + @Override + public Button controlify$getAddServerButton() { + return this.addServerButton; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/LanguageSelectionListEntryMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/LanguageSelectionListEntryMixin.java index 22b00f5c8..22487261c 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/LanguageSelectionListEntryMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/LanguageSelectionListEntryMixin.java @@ -6,26 +6,19 @@ */ package dev.isxander.controlify.mixins.feature.screenop.impl.outofgame; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.LanguageSelectionListComponentProcessor; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.LanguageSelectionBehaviour; import org.spongepowered.asm.mixin.Final; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; @Mixin(targets = "net.minecraft.client.gui.screens.options.LanguageSelectScreen$LanguageSelectionList$Entry") -public class LanguageSelectionListEntryMixin implements ComponentProcessorProvider { - @Shadow @Final String code; - - @Unique private LanguageSelectionListComponentProcessor controlify$componentProcessor = null; +public class LanguageSelectionListEntryMixin implements LanguageSelectionBehaviour.Access { + @Shadow + @Final + String code; @Override - public ComponentProcessor componentProcessor() { - // lazily create the component processor so `code` is defined - if (controlify$componentProcessor == null) - controlify$componentProcessor = new LanguageSelectionListComponentProcessor(code); - - return controlify$componentProcessor; + public String controlify$getLanguageCode() { + return this.code; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/ManageServerScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/ManageServerScreenMixin.java index 38e8500f1..332d7e9d0 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/ManageServerScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/ManageServerScreenMixin.java @@ -9,19 +9,17 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.AddServerLikeScreenProcessor; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.ServerAddressScreenAccess; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.ManageServerScreen; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; -import net.minecraft.client.gui.screens.ManageServerScreen; @Mixin(ManageServerScreen.class) -public class ManageServerScreenMixin implements ScreenProcessorProvider { +public class ManageServerScreenMixin implements ServerAddressScreenAccess { @Shadow private EditBox ipEdit; @@ -29,13 +27,6 @@ public class ManageServerScreenMixin implements ScreenProcessorProvider { private Button addButton; @Unique private Button cancelButton; - @Unique private final AddServerLikeScreenProcessor screenProcessor = new AddServerLikeScreenProcessor( - (ManageServerScreen) (Object) this, - () -> this.ipEdit, - () -> this.addButton, - () -> this.cancelButton - ); - @Definition(id = "builder", method = "Lnet/minecraft/client/gui/components/Button;builder(Lnet/minecraft/network/chat/Component;Lnet/minecraft/client/gui/components/Button$OnPress;)Lnet/minecraft/client/gui/components/Button$Builder;") @Definition(id = "GUI_CANCEL", field = "Lnet/minecraft/network/chat/CommonComponents;GUI_CANCEL:Lnet/minecraft/network/chat/Component;") @Definition(id = "build", method = "Lnet/minecraft/client/gui/components/Button$Builder;build()Lnet/minecraft/client/gui/components/Button;") @@ -46,7 +37,17 @@ private Button captureCancelButton(Button button) { } @Override - public ScreenProcessor screenProcessor() { - return screenProcessor; + public EditBox controlify$getAddressField() { + return this.ipEdit; + } + + @Override + public Button controlify$getDoneButton() { + return this.addButton; + } + + @Override + public Button controlify$getBackButton() { + return this.cancelButton; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/OptionsScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/OptionsScreenMixin.java index 145ddb2e1..7585d153b 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/OptionsScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/OptionsScreenMixin.java @@ -9,9 +9,7 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.OptionsScreenProcessor; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.DoneButtonGuideBehaviour; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.options.OptionsScreen; import org.spongepowered.asm.mixin.Mixin; @@ -19,14 +17,9 @@ import org.spongepowered.asm.mixin.injection.At; @Mixin(OptionsScreen.class) -public class OptionsScreenMixin implements ScreenProcessorProvider { +public class OptionsScreenMixin implements DoneButtonGuideBehaviour.Access { @Unique private Button doneButton; - @Unique private final OptionsScreenProcessor screenProcessor = new OptionsScreenProcessor( - (OptionsScreen) (Object) this, - () -> this.doneButton - ); - @Definition(id = "builder", method = "Lnet/minecraft/client/gui/components/Button;builder(Lnet/minecraft/network/chat/Component;Lnet/minecraft/client/gui/components/Button$OnPress;)Lnet/minecraft/client/gui/components/Button$Builder;") @Definition(id = "GUI_DONE", field = "Lnet/minecraft/network/chat/CommonComponents;GUI_DONE:Lnet/minecraft/network/chat/Component;") @Definition(id = "build", method = "Lnet/minecraft/client/gui/components/Button$Builder;build()Lnet/minecraft/client/gui/components/Button;") @@ -38,7 +31,7 @@ private Button captureDoneButton(Button button) { } @Override - public ScreenProcessor screenProcessor() { - return screenProcessor; + public Button controlify$getDoneButton() { + return this.doneButton; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/OptionsSubScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/OptionsSubScreenMixin.java index a15f368aa..7b795ee33 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/OptionsSubScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/OptionsSubScreenMixin.java @@ -9,9 +9,7 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.OptionsSubScreenProcessor; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.DoneButtonGuideBehaviour; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.options.OptionsSubScreen; import org.spongepowered.asm.mixin.Mixin; @@ -19,14 +17,9 @@ import org.spongepowered.asm.mixin.injection.At; @Mixin(OptionsSubScreen.class) -public class OptionsSubScreenMixin implements ScreenProcessorProvider { +public class OptionsSubScreenMixin implements DoneButtonGuideBehaviour.Access { @Unique private Button doneButton; - @Unique private final OptionsSubScreenProcessor screenProcessor = new OptionsSubScreenProcessor<>( - (OptionsSubScreen) (Object) this, - () -> this.doneButton - ); - @Definition(id = "builder", method = "Lnet/minecraft/client/gui/components/Button;builder(Lnet/minecraft/network/chat/Component;Lnet/minecraft/client/gui/components/Button$OnPress;)Lnet/minecraft/client/gui/components/Button$Builder;") @Definition(id = "GUI_DONE", field = "Lnet/minecraft/network/chat/CommonComponents;GUI_DONE:Lnet/minecraft/network/chat/Component;") @Definition(id = "build", method = "Lnet/minecraft/client/gui/components/Button$Builder;build()Lnet/minecraft/client/gui/components/Button;") @@ -38,7 +31,7 @@ private Button captureDoneButton(Button button) { } @Override - public ScreenProcessor screenProcessor() { - return screenProcessor; + public Button controlify$getDoneButton() { + return this.doneButton; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/PauseScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/PauseScreenMixin.java index 2a18151c5..307c8dade 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/PauseScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/PauseScreenMixin.java @@ -6,25 +6,20 @@ */ package dev.isxander.controlify.mixins.feature.screenop.impl.outofgame; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.PauseScreenProcessor; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.PauseScreenBehaviour; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.PauseScreen; import org.jetbrains.annotations.Nullable; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; -import org.spongepowered.asm.mixin.Unique; @Mixin(PauseScreen.class) -public class PauseScreenMixin implements ScreenProcessorProvider { - @Shadow private @Nullable Button disconnectButton; - - @Unique private final PauseScreenProcessor processor = - new PauseScreenProcessor((PauseScreen) (Object) this, () -> disconnectButton); +public class PauseScreenMixin implements PauseScreenBehaviour.Access { + @Shadow + private @Nullable Button disconnectButton; @Override - public ScreenProcessor screenProcessor() { - return processor; + public @Nullable Button controlify$getDisconnectButton() { + return this.disconnectButton; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/SelectWorldScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/SelectWorldScreenMixin.java index 50ecb363a..3472cdffe 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/SelectWorldScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/SelectWorldScreenMixin.java @@ -9,26 +9,15 @@ import com.llamalad7.mixinextras.expression.Definition; import com.llamalad7.mixinextras.expression.Expression; import com.llamalad7.mixinextras.injector.ModifyExpressionValue; -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.SelectWorldScreenProcessor; +import dev.isxander.controlify.api.gui.ButtonGuidePredicate; +import dev.isxander.controlify.impl.action.ControlifyActions; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.worldselection.SelectWorldScreen; import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; @Mixin(SelectWorldScreen.class) -public class SelectWorldScreenMixin implements ScreenProcessorProvider { - @Unique private final SelectWorldScreenProcessor processor = new SelectWorldScreenProcessor((SelectWorldScreen) (Object) this); - - @Override - public ScreenProcessor screenProcessor() { - return processor; - } +public class SelectWorldScreenMixin { @Definition(id = "builder", method = "Lnet/minecraft/client/gui/components/Button;builder(Lnet/minecraft/network/chat/Component;Lnet/minecraft/client/gui/components/Button$OnPress;)Lnet/minecraft/client/gui/components/Button$Builder;") @Definition(id = "GUI_BACK", field = "Lnet/minecraft/network/chat/CommonComponents;GUI_BACK:Lnet/minecraft/network/chat/Component;") @@ -36,9 +25,9 @@ public ScreenProcessor screenProcessor() { @Expression("builder(GUI_BACK, ?).?(?).build()") @ModifyExpressionValue(method = "createFooterButtons", at = @At("MIXINEXTRAS:EXPRESSION")) private Button modifyCancelButton(Button button) { - ButtonGuideApi.addGuideToButton( + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get((SelectWorldScreen) (Object) this).buttonGuide().addGuideToButton( button, - ControlifyBindings.GUI_BACK, + ControlifyActions.GUI_BACK, ButtonGuidePredicate.always() ); return button; @@ -50,9 +39,9 @@ private Button modifyCancelButton(Button button) { @Expression("builder(translatable('selectWorld.create'), ?).build()") @ModifyExpressionValue(method = "createFooterButtons", at = @At("MIXINEXTRAS:EXPRESSION")) private Button modifyCreateButton(Button button) { - ButtonGuideApi.addGuideToButton( + dev.isxander.controlify.impl.screenop.ScreenContextImpl.get((SelectWorldScreen) (Object) this).buttonGuide().addGuideToButton( button, - ControlifyBindings.GUI_ABSTRACT_ACTION_1, + ControlifyActions.GUI_ABSTRACT_ACTION_1, ButtonGuidePredicate.always() ); return button; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/ServerSelectionListEntryMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/ServerSelectionListEntryMixin.java deleted file mode 100644 index acb1a5cc4..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/ServerSelectionListEntryMixin.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.screenop.impl.outofgame; - -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.ServerSelectionListEntryComponentProcessor; -import net.minecraft.client.gui.screens.multiplayer.ServerSelectionList; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(ServerSelectionList.Entry.class) -public class ServerSelectionListEntryMixin implements ComponentProcessorProvider { - @Unique private final ServerSelectionListEntryComponentProcessor controlify$componentProcessor - = new ServerSelectionListEntryComponentProcessor(); - - @Override - public ComponentProcessor componentProcessor() { - return ((ServerSelectionList.Entry) (Object) this) instanceof ServerSelectionList.LANHeader - ? ComponentProcessor.EMPTY - : controlify$componentProcessor; - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/TitleScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/TitleScreenMixin.java deleted file mode 100644 index 02a6caf34..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/TitleScreenMixin.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.screenop.impl.outofgame; - -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.TitleScreenProcessor; -import net.minecraft.client.gui.screens.TitleScreen; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(TitleScreen.class) -public class TitleScreenMixin implements ScreenProcessorProvider { - @Unique private final ScreenProcessor processor - = new TitleScreenProcessor((TitleScreen) (Object) this); - - @Override - public ScreenProcessor screenProcessor() { - return processor; - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/WorldSelectionListEntryMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/WorldSelectionListEntryMixin.java deleted file mode 100644 index ddd0adb43..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/outofgame/WorldSelectionListEntryMixin.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.screenop.impl.outofgame; - -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.WorldListEntryComponentProcessor; -import net.minecraft.client.gui.screens.worldselection.WorldSelectionList; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Unique; - -@Mixin(WorldSelectionList.WorldListEntry.class) -public class WorldSelectionListEntryMixin implements ComponentProcessorProvider { - @Unique private final WorldListEntryComponentProcessor controlify$processor = new WorldListEntryComponentProcessor(); - - @Override - public ComponentProcessor componentProcessor() { - return controlify$processor; - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/sign/AbstractSignEditScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/sign/AbstractSignEditScreenMixin.java index c3557bf1f..476938ed4 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/sign/AbstractSignEditScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/impl/sign/AbstractSignEditScreenMixin.java @@ -12,13 +12,11 @@ import com.llamalad7.mixinextras.injector.wrapoperation.Operation; import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ScreenProcessorProvider; -import dev.isxander.controlify.screenop.compat.vanilla.AbstractSignEditScreenProcessor; -import dev.isxander.controlify.screenop.keyboard.KeyboardLayouts; -import dev.isxander.controlify.screenop.keyboard.KeyboardWidget; -import dev.isxander.controlify.screenop.keyboard.MixinInputTarget; +import dev.isxander.controlify.api.gui.keyboard.KeyboardLayouts; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.gui.keyboard.KeyboardWidget; +import dev.isxander.controlify.impl.gui.keyboard.MixinInputTarget; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.SignEditingBehaviour; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.events.GuiEventListener; @@ -39,25 +37,24 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; @Mixin(AbstractSignEditScreen.class) -public abstract class AbstractSignEditScreenMixin extends Screen implements ScreenProcessorProvider, MixinInputTarget { +public abstract class AbstractSignEditScreenMixin extends Screen implements SignEditingBehaviour.Access, MixinInputTarget { - @Shadow private @Nullable TextFieldHelper signField; - @Shadow @Final private String[] messages; - @Shadow private int line; - @Shadow protected abstract void onDone(); - @Shadow @Final protected SignBlockEntity sign; + @Shadow + private @Nullable TextFieldHelper signField; + @Shadow + @Final + private String[] messages; + @Shadow + private int line; - @Unique protected KeyboardWidget keyboard; - @Unique private final AbstractSignEditScreenProcessor screenProcessor = new AbstractSignEditScreenProcessor( - (AbstractSignEditScreen) (Object) this, - direction -> { - this.line = this.line + direction & 3; - this.signField.setCursorToEnd(); - }, - () -> this.sign, - () -> keyboard - ); + @Shadow + protected abstract void onDone(); + + @Shadow + @Final + protected SignBlockEntity sign; + @Unique protected KeyboardWidget keyboard; protected AbstractSignEditScreenMixin(Component title) { super(title); @@ -68,14 +65,14 @@ protected AbstractSignEditScreenMixin(Component title) { */ @Inject(method = "init", at = @At("HEAD")) private void addKeyboard(CallbackInfo ci) { - ControlifyApi.get().getCurrentController().ifPresent(c -> { + Controlify.instance().getCurrentController().ifPresent(c -> { // if the keyboard is already present, re-add it even if we're in kb/m mode since // setting fullscreen will turn it to that mode - if (!ControlifyApi.get().currentInputMode().isController() && this.keyboard == null) return; + if (!Controlify.instance().currentInputMode().isController() && this.keyboard == null) return; if (!c.settings().generic.keyboard.showOnScreenKeyboard) return; int keyboardHeight = (int) (this.height * 0.5f); - this.addRenderableWidget(this.keyboard = new KeyboardWidget(0, this.height - keyboardHeight, this.width, keyboardHeight, KeyboardLayouts.simple(), this)); + this.addRenderableWidget(this.keyboard = new KeyboardWidget(0, this.height - keyboardHeight, this.width, keyboardHeight, Controlify.instance().keyboardLayoutManager().getLayout(KeyboardLayouts.SIMPLE), this)); }); } @@ -160,7 +157,20 @@ private boolean preventDrawingTitle(GuiGraphicsExtractor instance, Font font, Co } @Override - public ScreenProcessor screenProcessor() { - return screenProcessor; + public void controlify$moveLine(int direction) { + this.line = this.line + direction & 3; + if (this.signField != null) { + this.signField.setCursorToEnd(); + } + } + + @Override + public SignBlockEntity controlify$getSign() { + return this.sign; + } + + @Override + public KeyboardWidget controlify$getKeyboard() { + return this.keyboard; } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/runtime/ScreenContextMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/runtime/ScreenContextMixin.java new file mode 100644 index 000000000..7966c302e --- /dev/null +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/runtime/ScreenContextMixin.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.mixins.feature.screenop.runtime; + +import dev.isxander.controlify.impl.screenop.ScreenContextHolder; +import dev.isxander.controlify.impl.screenop.ScreenContextImpl; +import net.minecraft.client.gui.screens.Screen; +import org.jetbrains.annotations.Nullable; +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(Screen.class) +public abstract class ScreenContextMixin implements ScreenContextHolder { + @Unique private ScreenContextImpl controlify$context; + @Unique private boolean controlify$creatingContext; + + @Override + public ScreenContextImpl controlify$screenContext() { + if (controlify$context == null) { + if (controlify$creatingContext) { + throw new IllegalStateException("Screen factory recursively resolved its own screen; use the supplied context"); + } + controlify$creatingContext = true; + try { + controlify$context = new ScreenContextImpl<>((Screen) (Object) this); + } finally { + controlify$creatingContext = false; + } + } + return controlify$context; + } + + @Override + public @Nullable ScreenContextImpl controlify$existingScreenContext() { + return controlify$context; + } + + @Inject(method = "init(II)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/gui/screens/Screen;init()V", shift = At.Shift.AFTER)) + private void controlify$initializedWidgets(CallbackInfo ci) { + if (controlify$context != null) { + controlify$context.onWidgetRebuild(); + } + } + + @Inject(method = "rebuildWidgets", at = @At("RETURN")) + private void controlify$rebuiltWidgets(CallbackInfo ci) { + if (controlify$context != null) { + controlify$context.onWidgetRebuild(); + } + } +} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/screenop/runtime/SliderAccessor.java b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/runtime/SliderAccessor.java new file mode 100644 index 000000000..f3febb474 --- /dev/null +++ b/src/main/java/dev/isxander/controlify/mixins/feature/screenop/runtime/SliderAccessor.java @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2026 isXander + * This file is part of Controlify. + * + * SPDX-License-Identifier: LGPL-3.0-or-later + */ +package dev.isxander.controlify.mixins.feature.screenop.runtime; + +import net.minecraft.client.gui.components.AbstractSliderButton; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(AbstractSliderButton.class) +public interface SliderAccessor { + @Accessor("canChangeValue") + boolean controlify$canChangeValue(); + + @Accessor("canChangeValue") + void controlify$setCanChangeValue(boolean canChangeValue); +} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/settingsbutton/ControlsScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/settingsbutton/ControlsScreenMixin.java index 1e82fa3fb..209ce2ab7 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/settingsbutton/ControlsScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/settingsbutton/ControlsScreenMixin.java @@ -6,8 +6,8 @@ */ package dev.isxander.controlify.mixins.feature.settingsbutton; -import dev.isxander.controlify.gui.screen.ControlifySettingsScreen; -import dev.isxander.controlify.utils.MinecraftUtil; +import dev.isxander.controlify.impl.gui.screen.ControlifySettingsScreen; +import dev.isxander.controlify.impl.utils.MinecraftUtil; import net.minecraft.client.Options; import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.options.OptionsSubScreen; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/util/ConnectScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/util/ConnectScreenMixin.java index a9c45a63a..4edf13e1b 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/util/ConnectScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/util/ConnectScreenMixin.java @@ -6,7 +6,7 @@ */ package dev.isxander.controlify.mixins.feature.util; -import dev.isxander.controlify.Controlify; +import dev.isxander.controlify.impl.Controlify; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.ConnectScreen; import net.minecraft.client.multiplayer.ServerData; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/GuiMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/GuiMixin.java index 1e2aa11d7..f5f91508c 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/GuiMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/GuiMixin.java @@ -6,27 +6,24 @@ */ package dev.isxander.controlify.mixins.feature.virtualmouse; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.virtualmouse.VirtualMouseHandler; +import dev.isxander.controlify.impl.screenop.ScreenRuntime; import net.minecraft.client.gui.screens.Screen; 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; -import java.util.Optional; @Mixin( //? if >=26.2 { net.minecraft.client.gui.Gui.class //?} else { - /*net.minecraft.client.Minecraft.class - *///?} + //net.minecraft.client.Minecraft.class + //?} ) public class GuiMixin { @Inject(method = "setScreen", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/Minecraft;updateTitle()V")) private void onScreenChanged(Screen screen, CallbackInfo ci) { - Optional.ofNullable(Controlify.instance().virtualMouseHandler()) - .ifPresent(VirtualMouseHandler::onScreenChanged); + ScreenRuntime.onScreenChanged(); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/InputConstantsMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/InputConstantsMixin.java index 474b93537..12dc9b494 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/InputConstantsMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/InputConstantsMixin.java @@ -9,9 +9,9 @@ import com.llamalad7.mixinextras.injector.ModifyReturnValue; import com.llamalad7.mixinextras.sugar.Local; import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.impl.action.ControlifyActions; +import dev.isxander.controlify.impl.controller.ControllerImpl; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -21,12 +21,14 @@ public class InputConstantsMixin { @ModifyReturnValue(method = "isKeyDown", at = @At("RETURN")) private static boolean modifyIsKeyDown(boolean keyDown, @Local(argsOnly = true, name = "key") int key) { if (key == InputConstants.KEY_LSHIFT) { - ControllerEntity controller = Controlify.instance().getCurrentController().orElse(null); - if (controller == null) return keyDown; + ControllerImpl controller = Controlify.instance().getCurrentController().orElse(null); + if (controller == null) { + return keyDown; + } return keyDown - || ControlifyBindings.VMOUSE_SHIFT_CLICK.on(controller).digitalNow() - || ControlifyBindings.VMOUSE_SHIFT.on(controller).digitalNow(); + || ControlifyActions.VMOUSE_SHIFT_CLICK.on(controller).latch().isLatchActive() + || ControlifyActions.VMOUSE_SHIFT.on(controller).latch().isLatchActive(); } return keyDown; diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/MinecraftMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/MinecraftMixin.java index 286ad0ed1..f19efbf69 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/MinecraftMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/MinecraftMixin.java @@ -6,21 +6,18 @@ */ package dev.isxander.controlify.mixins.feature.virtualmouse; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.virtualmouse.VirtualMouseHandler; +import dev.isxander.controlify.impl.screenop.ScreenRuntime; import net.minecraft.client.Minecraft; 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; -import java.util.Optional; @Mixin(Minecraft.class) public class MinecraftMixin { @Inject(method = "runTick", at = @At(value = "INVOKE", target = "Lnet/minecraft/client/MouseHandler;handleAccumulatedMovement()V")) private void onUpdateMouse(boolean advanceGameTime, CallbackInfo ci) { - Optional.ofNullable(Controlify.instance().virtualMouseHandler()) - .ifPresent(VirtualMouseHandler::updateMouse); + ScreenRuntime.update(); } } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/MouseHandlerMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/MouseHandlerMixin.java index 3104db56b..2617881ca 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/MouseHandlerMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/MouseHandlerMixin.java @@ -8,8 +8,8 @@ import com.llamalad7.mixinextras.injector.v2.WrapWithCondition; import com.mojang.blaze3d.platform.Window; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; +import dev.isxander.controlify.impl.Controlify; +import dev.isxander.controlify.api.InputMode; import net.minecraft.client.MouseHandler; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; @@ -23,8 +23,8 @@ public class MouseHandlerMixin { //? if >=26.3 { target = "Lcom/mojang/blaze3d/platform/InputConstants;releaseMouse(Lcom/mojang/blaze3d/platform/Window;DD)V" //?} else { - /*target = "Lcom/mojang/blaze3d/platform/InputConstants;grabOrReleaseMouse(Lcom/mojang/blaze3d/platform/Window;IDD)V" - *///?} + //target = "Lcom/mojang/blaze3d/platform/InputConstants;grabOrReleaseMouse(Lcom/mojang/blaze3d/platform/Window;IDD)V" + //?} ) ) private boolean shouldReleaseMouse(Window window, /*? if <26.3 {*//*int cursorMode,*//*?}*/ double xpos, double ypos) { diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/AbstractContainerScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/AbstractContainerScreenMixin.java deleted file mode 100644 index 1b6a9dde1..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/AbstractContainerScreenMixin.java +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.virtualmouse.snapping; - -import dev.isxander.controlify.api.vmousesnapping.ISnapBehaviour; -import dev.isxander.controlify.api.vmousesnapping.SnapPoint; -import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; -import net.minecraft.world.inventory.AbstractContainerMenu; -import org.joml.Vector2i; -import org.spongepowered.asm.mixin.Final; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; - -import java.util.function.Consumer; - -@Mixin(AbstractContainerScreen.class) -public abstract class AbstractContainerScreenMixin extends ScreenMixin implements ISnapBehaviour { - - @Shadow - public abstract T getMenu(); - - @Shadow - protected int leftPos; - - @Shadow - protected int topPos; - - @Final @Shadow - protected int imageWidth; - - @Final @Shadow - protected int imageHeight; - - @Shadow - @Final - protected T menu; - - @Override - public void controlify$collectSnapPoints(Consumer consumer) { - super.controlify$collectSnapPoints(consumer); - - getMenu().slots.stream() - .map(slot -> new SnapPoint(new Vector2i(leftPos + slot.x + 8, topPos + slot.y + 8), 17)) - .forEach(consumer); - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/AbstractRecipeBookScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/AbstractRecipeBookScreenMixin.java deleted file mode 100644 index 54dd287ce..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/AbstractRecipeBookScreenMixin.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.virtualmouse.snapping; - -import dev.isxander.controlify.api.vmousesnapping.SnapPoint; -import dev.isxander.controlify.virtualmouse.SnapUtils; -import net.minecraft.client.gui.screens.inventory.*; -import net.minecraft.client.gui.screens.recipebook.RecipeBookComponent; -import net.minecraft.world.inventory.AbstractContainerMenu; -import org.spongepowered.asm.mixin.Final; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; - -import java.util.function.Consumer; - -@Mixin(AbstractRecipeBookScreen.class) -public abstract class AbstractRecipeBookScreenMixin - extends AbstractContainerScreenMixin { - - @Shadow @Final private RecipeBookComponent recipeBookComponent; - - @Override - public void controlify$collectSnapPoints(Consumer consumer) { - super.controlify$collectSnapPoints(consumer); - - SnapUtils.addRecipeSnapPoints(recipeBookComponent, consumer); - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/CreativeModeInventoryScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/CreativeModeInventoryScreenMixin.java index dd3157dcb..8eb2257fb 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/CreativeModeInventoryScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/CreativeModeInventoryScreenMixin.java @@ -6,29 +6,42 @@ */ package dev.isxander.controlify.mixins.feature.virtualmouse.snapping; -import dev.isxander.controlify.api.vmousesnapping.SnapPoint; +import dev.isxander.controlify.api.screenop.SnapPoint; +import dev.isxander.controlify.impl.screenop.behaviours.vanilla.CreativeModeInventoryScreenBehaviour; import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.item.CreativeModeTab; -import net.minecraft.world.item.CreativeModeTabs; import org.joml.Vector2i; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; +import java.util.List; import java.util.function.Consumer; @Mixin(CreativeModeInventoryScreen.class) -public abstract class CreativeModeInventoryScreenMixin extends AbstractContainerScreenMixin { - @Shadow protected abstract int getTabX(CreativeModeTab group); - @Shadow private float scrollOffs; - @Shadow private EditBox searchBox; - @Shadow protected abstract boolean canScroll(); +public abstract class CreativeModeInventoryScreenMixin extends AbstractContainerScreen implements CreativeModeInventoryScreenBehaviour.Access { + @Shadow + protected abstract int getTabX(CreativeModeTab group); + + @Shadow + private float scrollOffs; + @Shadow + private EditBox searchBox; + + @Shadow + protected abstract boolean canScroll(); + + protected CreativeModeInventoryScreenMixin(CreativeModeInventoryScreen.ItemPickerMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + } @Override - public void controlify$collectSnapPoints(Consumer consumer) { - super.controlify$collectSnapPoints(consumer); + public void controlify$collectTabSnapPoints(List tabs, Consumer consumer) { - for (var tab : CreativeModeTabs.tabs()) { + for (var tab : tabs) { boolean topRow = tab.row() == CreativeModeTab.Row.TOP; int x = leftPos + getTabX(tab); int y = topPos + (topRow ? -28 : imageHeight - 4); @@ -39,7 +52,7 @@ public abstract class CreativeModeInventoryScreenMixin extends AbstractContainer if (canScroll()) { int scrollTop = topPos + 18; int scrollBottom = scrollTop + 112; - consumer.accept(new SnapPoint(new Vector2i(leftPos + 175 + 6, scrollTop + (int)((float)(scrollBottom - scrollTop - 17) * scrollOffs) + 7), 15)); + consumer.accept(new SnapPoint(new Vector2i(leftPos + 175 + 6, scrollTop + (int) ((float) (scrollBottom - scrollTop - 17) * scrollOffs) + 7), 15)); } if (searchBox.isVisible()) diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/EnchantmentScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/EnchantmentScreenMixin.java index d1500bfba..8acf670cd 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/EnchantmentScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/EnchantmentScreenMixin.java @@ -6,29 +6,35 @@ */ package dev.isxander.controlify.mixins.feature.virtualmouse.snapping; -import dev.isxander.controlify.api.vmousesnapping.ISnapBehaviour; -import dev.isxander.controlify.api.vmousesnapping.SnapPoint; +import dev.isxander.controlify.api.screenop.SnapPoint; +import dev.isxander.controlify.impl.screenop.behaviours.screen.AdditionalSnapPointsBehaviour; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.EnchantmentScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.EnchantmentMenu; +import org.jspecify.annotations.NonNull; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Unique; import java.util.function.Consumer; @Mixin(EnchantmentScreen.class) -public abstract class EnchantmentScreenMixin extends AbstractContainerScreenMixin implements ISnapBehaviour { +public abstract class EnchantmentScreenMixin extends AbstractContainerScreen implements AdditionalSnapPointsBehaviour.Access { @Unique private static final int SLOT_AREA_OFFSET_X = 60, SLOT_AREA_OFFSET_Y = 14; @Unique private static final int SLOT_WIDTH = 108, SLOT_HEIGHT = 19; @Unique private static final int SLOT_VERTICAL_SPACING = SLOT_HEIGHT; // This math is based on the renderBg method in EnchantmentScreen - @Override - public void controlify$collectSnapPoints(Consumer consumer) { - super.controlify$collectSnapPoints(consumer); + protected EnchantmentScreenMixin(EnchantmentMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + } - int leftPos = (this.width - this.imageWidth) / 2; - int topPos = (this.height - this.imageHeight) / 2; + @Override + public void controlify$collectSnapPoints(@NonNull Consumer consumer) { + int leftPos = this.leftPos; + int topPos = this.topPos; int snapRadius = (SLOT_HEIGHT / 2) + 5; // enchanting table has 3 options diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/LoomScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/LoomScreenMixin.java index 7c7200803..0cb153655 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/LoomScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/LoomScreenMixin.java @@ -6,12 +6,16 @@ */ package dev.isxander.controlify.mixins.feature.virtualmouse.snapping; -import dev.isxander.controlify.api.vmousesnapping.ISnapBehaviour; -import dev.isxander.controlify.api.vmousesnapping.SnapPoint; +import dev.isxander.controlify.api.screenop.SnapPoint; +import dev.isxander.controlify.impl.screenop.behaviours.screen.AdditionalSnapPointsBehaviour; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.LoomScreen; import net.minecraft.core.Holder; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.LoomMenu; import net.minecraft.world.level.block.entity.BannerPattern; +import org.jspecify.annotations.NonNull; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; @@ -20,19 +24,23 @@ import java.util.function.Consumer; @Mixin(LoomScreen.class) -public abstract class LoomScreenMixin extends AbstractContainerScreenMixin implements ISnapBehaviour { +public abstract class LoomScreenMixin extends AbstractContainerScreen implements AdditionalSnapPointsBehaviour.Access { @Unique private static final int COLUMNS = 4, ROWS = 4, SLOTS = COLUMNS * ROWS; @Unique private static final int BTN_SIZE = 14; - @Shadow private boolean displayPatterns; - @Shadow private int startRow; + @Shadow + private boolean displayPatterns; + @Shadow + private int startRow; // This math is based on the renderBg method in LoomScreen - @Override - public void controlify$collectSnapPoints(Consumer consumer) { - super.controlify$collectSnapPoints(consumer); + protected LoomScreenMixin(LoomMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + } + @Override + public void controlify$collectSnapPoints(@NonNull Consumer consumer) { if (!this.displayPatterns) { return; } diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/ScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/ScreenMixin.java deleted file mode 100644 index ee2b5dc2f..000000000 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/ScreenMixin.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.mixins.feature.virtualmouse.snapping; - -import dev.isxander.controlify.api.vmousesnapping.ISnapBehaviour; -import dev.isxander.controlify.api.vmousesnapping.SnapPoint; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.components.AbstractWidget; -import net.minecraft.client.gui.components.events.GuiEventListener; -import net.minecraft.client.gui.screens.Screen; -import org.jetbrains.annotations.Nullable; -import org.joml.Vector2i; -import org.spongepowered.asm.mixin.Final; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.Shadow; - -import java.util.List; -import java.util.function.Consumer; - -@Mixin(Screen.class) -public abstract class ScreenMixin implements ISnapBehaviour { - @Shadow public abstract List children(); - - @Shadow public int width; - @Shadow public int height; - @Final @Shadow protected @Nullable Minecraft minecraft; - - @Override - public void controlify$collectSnapPoints(Consumer consumer) { - children().stream() - .filter(child -> child instanceof AbstractWidget) - .map(AbstractWidget.class::cast) - .map(widget -> new SnapPoint( - new Vector2i(widget.getX() + widget.getWidth() / 2, widget.getY() + widget.getHeight() / 2), - Math.min(widget.getWidth(), widget.getHeight()) / 2 + 10 - )) - .forEach(consumer); - } -} diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/StonecutterScreenMixin.java b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/StonecutterScreenMixin.java index 0280af8b2..efd7217dc 100644 --- a/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/StonecutterScreenMixin.java +++ b/src/main/java/dev/isxander/controlify/mixins/feature/virtualmouse/snapping/StonecutterScreenMixin.java @@ -6,10 +6,14 @@ */ package dev.isxander.controlify.mixins.feature.virtualmouse.snapping; -import dev.isxander.controlify.api.vmousesnapping.ISnapBehaviour; -import dev.isxander.controlify.api.vmousesnapping.SnapPoint; +import dev.isxander.controlify.api.screenop.SnapPoint; +import dev.isxander.controlify.impl.screenop.behaviours.screen.AdditionalSnapPointsBehaviour; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.StonecutterScreen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.entity.player.Inventory; import net.minecraft.world.inventory.StonecutterMenu; +import org.jspecify.annotations.NonNull; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; import org.spongepowered.asm.mixin.Unique; @@ -17,19 +21,22 @@ import java.util.function.Consumer; @Mixin(StonecutterScreen.class) -public abstract class StonecutterScreenMixin extends AbstractContainerScreenMixin implements ISnapBehaviour { +public abstract class StonecutterScreenMixin extends AbstractContainerScreen implements AdditionalSnapPointsBehaviour.Access { @Unique private static final int COLUMNS = 4, ROWS = 3, SLOTS = COLUMNS * ROWS; @Unique private static final int BTN_WIDTH = 16, BTN_HEIGHT = 18; @Unique private static final int BTN_Y_PADDING = 2; - @Shadow private int startIndex; + @Shadow + private int startIndex; // This math is based on the renderBg method in StonecutterScreen - @Override - public void controlify$collectSnapPoints(Consumer consumer) { - super.controlify$collectSnapPoints(consumer); + protected StonecutterScreenMixin(StonecutterMenu menu, Inventory inventory, Component title) { + super(menu, inventory, title); + } + @Override + public void controlify$collectSnapPoints(@NonNull Consumer consumer) { int startIndex = this.startIndex; int endIndex = this.startIndex + SLOTS; int visibleRecipes = this.menu.getNumberOfVisibleRecipes(); diff --git a/src/main/java/dev/isxander/controlify/rumble/BasicRumbleEffect.java b/src/main/java/dev/isxander/controlify/rumble/BasicRumbleEffect.java deleted file mode 100644 index 89a3d45a2..000000000 --- a/src/main/java/dev/isxander/controlify/rumble/BasicRumbleEffect.java +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.rumble; - -import dev.isxander.controlify.utils.MinecraftUtil; -import net.minecraft.client.gui.screens.Screen; -import org.apache.commons.lang3.Validate; - -import java.util.Arrays; -import java.util.Objects; -import java.util.function.BooleanSupplier; -import java.util.function.Function; - -public final class BasicRumbleEffect implements RumbleEffect { - private final RumbleState[] keyframes; - private int tick = 0; - private boolean finished; - private int priority = 0; - private BooleanSupplier earlyFinishCondition = () -> false; - - public BasicRumbleEffect(RumbleState[] keyframes) { - this.keyframes = keyframes; - } - - @Override - public void tick() { - tick++; - if (tick >= keyframes.length || earlyFinishCondition.getAsBoolean()) - finished = true; - } - - @Override - public RumbleState currentState() { - if (tick == 0) - throw new IllegalStateException("Effect hasn't ticked yet."); - - return keyframes[tick - 1]; - } - - @Override - public int age() { - return tick; - } - - @Override - public boolean isFinished() { - return finished; - } - - @Override - public int priority() { - return priority; - } - - public BasicRumbleEffect prioritised(int priority) { - this.priority = priority; - return this; - } - - public RumbleState[] states() { - return keyframes; - } - - public BasicRumbleEffect earlyFinish(BooleanSupplier condition) { - var current = earlyFinishCondition; - this.earlyFinishCondition = () -> current.getAsBoolean() || condition.getAsBoolean(); - return this; - } - - @Override - public boolean equals(Object obj) { - if (obj == this) return true; - if (obj == null || obj.getClass() != this.getClass()) return false; - var that = (BasicRumbleEffect) obj; - return Arrays.equals(this.states(), that.states()) - && this.priority() == that.priority(); - } - - @Override - public int hashCode() { - return Objects.hash(Arrays.hashCode(this.states()), this.priority()); - } - - @Override - public String toString() { - return "RumbleEffect[" + - "states=" + Arrays.toString(this.states()) + ',' + - "priority=" + this.priority() + ']'; - } - - public BasicRumbleEffect seq(BasicRumbleEffect other) { - return BasicRumbleEffect.seq(this, other); - } - - public BasicRumbleEffect repeat(int count) { - Validate.isTrue(count > 0, "count must be greater than 0"); - - if (count == 1) return this; - - BasicRumbleEffect effect = this; - for (int i = 0; i < count - 1; i++) { - effect = BasicRumbleEffect.seq(effect, this); - } - return effect; - } - - /** - * Creates a rumble effect where the state is determined by the tick. - * - * @param stateFunction the function that takes a tick and returns the state for that tick. - * @param durationTicks how many ticks the effect should last for. - */ - public static BasicRumbleEffect byTick(Function stateFunction, int durationTicks) { - RumbleState[] states = new RumbleState[durationTicks]; - for (int i = 0; i < durationTicks; i++) { - states[i] = stateFunction.apply(i); - } - return new BasicRumbleEffect(states); - } - - /** - * Creates a rumble effect from a function that takes a time value from 0, start, to 1, end, and returns that tick. - * - * @param stateFunction the function that takes the time value and returns the state for that tick. - * @param durationTicks how many ticks the effect should last for. - */ - public static BasicRumbleEffect byTime(Function stateFunction, int durationTicks) { - return BasicRumbleEffect.byTick(tick -> stateFunction.apply((float) tick / (float) durationTicks), durationTicks); - } - - /** - * Creates a rumble effect that has a constant state. - * - * @param strong the strong motor magnitude. - * @param weak the weak motor magnitude - * @param durationTicks how many ticks the effect should last for. - */ - public static BasicRumbleEffect constant(float strong, float weak, int durationTicks) { - return BasicRumbleEffect.byTick(tick -> new RumbleState(strong, weak), durationTicks); - } - - public static BasicRumbleEffect empty(int durationTicks) { - return BasicRumbleEffect.byTick(tick -> new RumbleState(0f, 0f), durationTicks); - } - - public static BasicRumbleEffect seq(BasicRumbleEffect... effects) { - RumbleState[] states = Arrays.stream(effects) - .flatMap(effect -> Arrays.stream(effect.states())) - .toArray(RumbleState[]::new); - - return new BasicRumbleEffect(states); - } - - public static BooleanSupplier finishOnScreenChange() { - Screen screen = MinecraftUtil.getScreen(); - return () -> screen != MinecraftUtil.getScreen(); - } -} diff --git a/src/main/java/dev/isxander/controlify/rumble/ContinuousRumbleEffect.java b/src/main/java/dev/isxander/controlify/rumble/ContinuousRumbleEffect.java deleted file mode 100644 index 5b14b14d4..000000000 --- a/src/main/java/dev/isxander/controlify/rumble/ContinuousRumbleEffect.java +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.rumble; - -import net.minecraft.client.Minecraft; -import net.minecraft.util.Mth; -import net.minecraft.world.phys.Vec3; -import org.apache.commons.lang3.Validate; - -import java.util.function.BooleanSupplier; -import java.util.function.Function; -import java.util.function.Supplier; - -public class ContinuousRumbleEffect implements RumbleEffect { - private final Function stateFunction; - private final int priority; - private final int timeout; - private final int minTime; - private int tick; - private int age; - private boolean stopped; - private BooleanSupplier stopCondition; - - public ContinuousRumbleEffect(Function stateFunction, int priority, int timeout, int minTime, BooleanSupplier stopCondition) { - this.stateFunction = stateFunction; - this.priority = priority; - this.timeout = timeout; - this.minTime = minTime; - this.stopCondition = stopCondition; - } - - @Override - public void tick() { - tick++; - age++; - if (stopCondition.getAsBoolean()) - stop(); - } - - @Override - public RumbleState currentState() { - if (tick == 0) - throw new IllegalStateException("Effect hasn't ticked yet."); - - return stateFunction.apply(tick - 1); - } - - public void stop() { - stopped = true; - } - - public void heartbeat() { - age = 0; - } - - @Override - public int age() { - return tick; - } - - @Override - public boolean isFinished() { - return (stopped || (timeout > 0 && age >= timeout)) && tick >= minTime; - } - - @Override - public int priority() { - return this.priority; - } - - public static Builder builder() { - return new Builder(); - } - - public static class Builder { - private Function stateFunction; - private int priority; - private int timeout = -1; - private int minTime; - private InWorldProperties inWorldProperties; - private BooleanSupplier stopCondition = () -> false; - - private Builder() { - } - - public Builder byTick(Function stateFunction) { - this.stateFunction = stateFunction; - return this; - } - - public Builder constant(RumbleState state) { - this.stateFunction = tick -> state; - return this; - } - - public Builder constant(float strong, float weak) { - return this.constant(new RumbleState(strong, weak)); - } - - public Builder timeout(int timeoutTicks) { - Validate.isTrue(timeoutTicks >= 0, "the timeout cannot be negative!"); - - this.timeout = timeoutTicks; - return this; - } - - public Builder minTime(int minTimeTicks) { - Validate.isTrue(minTimeTicks >= 0, "the minimum time cannot be negative!"); - - this.minTime = minTimeTicks; - return this; - } - - public Builder priority(int priority) { - this.priority = priority; - return this; - } - - public Builder inWorld(Supplier sourceLocation, float min, float max, float effectRange, Function fallofFunction) { - this.inWorldProperties = new InWorldProperties(sourceLocation, min, max, effectRange, fallofFunction); - stopCondition(() -> Minecraft.getInstance().getCameraEntity() == null); - return this; - } - - public Builder stopCondition(BooleanSupplier stopCondition) { - BooleanSupplier oldStopCondition = this.stopCondition; - this.stopCondition = () -> stopCondition.getAsBoolean() || oldStopCondition.getAsBoolean(); - return this; - } - - public ContinuousRumbleEffect build() { - Validate.notNull(stateFunction, "stateFunction cannot be null!"); - Validate.isTrue(minTime <= timeout || timeout == -1, "the minimum time cannot be greater than the timeout!"); - - var stateFunction = this.stateFunction; - if (inWorldProperties != null) - stateFunction = inWorldProperties.modify(stateFunction); - - return new ContinuousRumbleEffect(stateFunction, priority, timeout, minTime, stopCondition); - } - - private record InWorldProperties(Supplier sourceLocation, float minMagnitude, float maxMagnitude, float effectRange, Function fallofFunction) { - private Function modify(Function stateFunction) { - return tick -> { - if (Minecraft.getInstance().getCameraEntity() == null) - return RumbleState.NONE; - - float distanceSqr = (float) Minecraft.getInstance().getCameraEntity().distanceToSqr(sourceLocation.get()); - float normalizedDistance = Mth.clamp(distanceSqr / (effectRange * effectRange), 0, 1); - float multiplier = Mth.lerp(fallofFunction.apply(1f - normalizedDistance), minMagnitude, maxMagnitude); - - return stateFunction.apply(tick).mul(multiplier); - }; - } - } - } -} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleCapable.java b/src/main/java/dev/isxander/controlify/rumble/RumbleCapable.java deleted file mode 100644 index 283bd06c2..000000000 --- a/src/main/java/dev/isxander/controlify/rumble/RumbleCapable.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.rumble; - -public interface RumbleCapable { - boolean setRumble(float strongMagnitude, float weakMagnitude); - - boolean supportsRumble(); - - RumbleState applyRumbleSourceStrength(RumbleState state, RumbleSource source); -} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleEffect.java b/src/main/java/dev/isxander/controlify/rumble/RumbleEffect.java deleted file mode 100644 index 365470449..000000000 --- a/src/main/java/dev/isxander/controlify/rumble/RumbleEffect.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.rumble; - -public interface RumbleEffect extends Comparable { - void tick(); - RumbleState currentState(); - - boolean isFinished(); - - int priority(); - int age(); - - @Override - default int compareTo(RumbleEffect o) { - int priorityCompare = Integer.compare(o.priority(), this.priority()); - if (priorityCompare != 0) return priorityCompare; - return Integer.compare(this.age(), o.age()); - } -} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleManager.java b/src/main/java/dev/isxander/controlify/rumble/RumbleManager.java deleted file mode 100644 index 0639896c5..000000000 --- a/src/main/java/dev/isxander/controlify/rumble/RumbleManager.java +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.rumble; - -import dev.isxander.controlify.controller.rumble.RumbleComponent; -import org.jetbrains.annotations.NotNull; - -import java.util.Comparator; -import java.util.PriorityQueue; -import java.util.Queue; - -public class RumbleManager { - private final RumbleComponent controller; - private final Queue effectQueue; - - private boolean silent, wasSilent; - - public RumbleManager(RumbleComponent controller) { - this.controller = controller; - this.effectQueue = new PriorityQueue<>(Comparator.comparing(RumbleEffectInstance::effect)); - } - - @Deprecated - public void play(RumbleEffect effect) { - play(RumbleSource.MASTER, effect); - } - - public void play(RumbleSource source, RumbleEffect effect) { - effectQueue.add(new RumbleEffectInstance(source, effect)); - } - - public void tick() { - effectQueue.removeIf(e -> e.effect().isFinished()); - effectQueue.forEach(e -> e.effect().tick()); - - if (effectQueue.isEmpty()) { - clearRumble(); - return; - } - - float strong = 0f, weak = 0f; - for (RumbleEffectInstance effect : effectQueue) { - RumbleState effectState = controller.settings().applyRumbleStrength(effect.effect().currentState(), effect.source()); - strong = Math.max(strong, effectState.strong()); - weak = Math.max(weak, effectState.weak()); - } - RumbleState state = new RumbleState(strong, weak); - - if (state.isZero()) { - clearRumble(); - return; - } - - if (silent) { - clearRumble(); - } else { - controller.queueRumble(state); - wasSilent = false; - } - } - - private void clearRumble() { - if (wasSilent) - return; - - controller.queueRumble(RumbleState.NONE); - wasSilent = true; - } - - public void clearEffects() { - effectQueue.clear(); - } - - public void setSilent(boolean silent) { - this.silent = silent; - } - - public boolean isPlaying() { - return !effectQueue.isEmpty(); - } - - private record RumbleEffectInstance(RumbleSource source, RumbleEffect effect) implements Comparable { - @Override - public int compareTo(@NotNull RumbleManager.RumbleEffectInstance o) { - return effect.compareTo(o.effect); - } - } -} diff --git a/src/main/java/dev/isxander/controlify/rumble/RumbleSource.java b/src/main/java/dev/isxander/controlify/rumble/RumbleSource.java deleted file mode 100644 index 5fe9d27d9..000000000 --- a/src/main/java/dev/isxander/controlify/rumble/RumbleSource.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.rumble; - -import com.google.gson.JsonObject; -import com.mojang.serialization.Codec; -import dev.isxander.controlify.utils.CUtil; -import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap; -import net.minecraft.resources.Identifier; - -import java.util.*; - -public record RumbleSource(Identifier id) { - public static final Codec CODEC = Identifier.CODEC - .xmap(RumbleSource::get, RumbleSource::id); - - private static final Map SOURCES = new Object2ObjectLinkedOpenHashMap<>(); - - public static final RumbleSource - MASTER = register("master"), - PLAYER = register("player"), - WORLD = register("world"), - INTERACTION = register("interaction"), - GUI = register("gui"); - - public static RumbleSource get(Identifier id) { - RumbleSource source = SOURCES.get(id); - if (source == null) { - CUtil.LOGGER.warn("Unknown rumble source: {}. Using master.", id); - return MASTER; - } - return source; - } - - public static Collection values() { - return SOURCES.values(); - } - - public static JsonObject getDefaultJson() { - JsonObject object = new JsonObject(); - for (RumbleSource source : SOURCES.values()) { - object.addProperty(source.id().toString(), 1f); - } - return object; - } - - public static Map getDefaultMap() { - Map map = new HashMap<>(); - - for (RumbleSource source : SOURCES.values()) { - map.put(source.id(), 1f); - } - - return map; - } - - public static RumbleSource register(Identifier id) { - var source = new RumbleSource(id); - SOURCES.put(id, source); - return source; - } - - public static RumbleSource register(String identifier, String path) { - return register(Identifier.fromNamespaceAndPath(identifier, path)); - } - - private static RumbleSource register(String path) { - return register("controlify", path); - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/ComponentProcessor.java b/src/main/java/dev/isxander/controlify/screenop/ComponentProcessor.java deleted file mode 100644 index 7afa9fdb5..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/ComponentProcessor.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop; - -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.keyboard.ComponentKeyboardBehaviour; - -public interface ComponentProcessor extends ComponentProcessorProvider { - ComponentProcessor EMPTY = new ComponentProcessor(){}; - - default boolean overrideControllerNavigation(ScreenProcessor screen, ControllerEntity controller) { - return false; - } - - default boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) { - return false; - } - - default void onFocusGained(ScreenProcessor screen, ControllerEntity controller) { - } - - default boolean shouldKeepFocusOnKeyboardMode(ScreenProcessor screen) { - return false; - } - - default ComponentKeyboardBehaviour getKeyboardBehaviour(ScreenProcessor screen, ControllerEntity controller) { - return ComponentKeyboardBehaviour.UNDEFINED; - } - - @Override - default ComponentProcessor componentProcessor() { - return this; - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/ComponentProcessorProvider.java b/src/main/java/dev/isxander/controlify/screenop/ComponentProcessorProvider.java deleted file mode 100644 index 975013be6..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/ComponentProcessorProvider.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop; - -import net.minecraft.client.gui.components.events.GuiEventListener; - -public interface ComponentProcessorProvider { - ComponentProcessor componentProcessor(); - - static ComponentProcessor provide(GuiEventListener component) { - if (component == null) { - return ComponentProcessor.EMPTY; - } - - if (component instanceof ComponentProcessorProvider provider) - return provider.componentProcessor(); - - return REGISTRY.get(component).orElse(ComponentProcessor.EMPTY); - - } - - Registry REGISTRY = new Registry<>(); -} diff --git a/src/main/java/dev/isxander/controlify/screenop/CustomFocus.java b/src/main/java/dev/isxander/controlify/screenop/CustomFocus.java deleted file mode 100644 index 911b95650..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/CustomFocus.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop; - -import net.minecraft.client.gui.components.events.GuiEventListener; - -public interface CustomFocus { - GuiEventListener getCustomFocus(); -} diff --git a/src/main/java/dev/isxander/controlify/screenop/Registry.java b/src/main/java/dev/isxander/controlify/screenop/Registry.java deleted file mode 100644 index a40487638..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/Registry.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop; - -import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; -import org.jetbrains.annotations.ApiStatus; - -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; - -@SuppressWarnings("unchecked") -public class Registry { - private final Map, Function> registry; - private final Map cache; - - public Registry() { - this.registry = new Object2ObjectOpenHashMap<>(); - this.cache = new Object2ObjectOpenHashMap<>(); - } - - /** - * Registers a constructor for a class - * - * @param clazz the class to bind the constructor to - * @param constructor function to build the object from the class - * @param type of class - */ - public void register(Class clazz, Function constructor) { - registry.put(clazz, (Function) constructor); - } - - Optional get(T object) { - U cached = this.cache.get(object); - if (cached != null) - return Optional.of(cached); - - Class clazz = (Class) object.getClass(); - Function constructor = registry.get(clazz); - if (constructor == null) - return Optional.empty(); - - U constructed = constructor.apply(object); - this.cache.put(object, constructed); - return Optional.of(constructed); - } - - @ApiStatus.Internal - public void clearCache() { - this.cache.clear(); - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/ScreenControllerEventListener.java b/src/main/java/dev/isxander/controlify/screenop/ScreenControllerEventListener.java deleted file mode 100644 index d3dccd778..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/ScreenControllerEventListener.java +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop; - -import dev.isxander.controlify.controller.ControllerEntity; - -public interface ScreenControllerEventListener { - default void onControllerInput(ControllerEntity controller) {} -} diff --git a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessor.java b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessor.java deleted file mode 100644 index 64eeb95dd..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessor.java +++ /dev/null @@ -1,367 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop; - -import com.mojang.blaze3d.platform.InputConstants; -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.api.bind.InputBinding; -import dev.isxander.controlify.controller.*; -import dev.isxander.controlify.controller.haptic.HapticEffects; -import dev.isxander.controlify.controller.input.ControllerStateView; -import dev.isxander.controlify.controller.input.GamepadInputs; -import dev.isxander.controlify.controller.input.InputComponent; -import dev.isxander.controlify.mixins.feature.screenop.ScreenAccessor; -import dev.isxander.controlify.mixins.feature.screenop.impl.outofgame.TabNavigationBarAccessor; -import dev.isxander.controlify.screenop.keyboard.*; -import dev.isxander.controlify.sound.ControlifyClientSounds; -import dev.isxander.controlify.utils.HoldRepeatHelper; -import dev.isxander.controlify.utils.MinecraftUtil; -import dev.isxander.controlify.virtualmouse.VirtualMouseBehaviour; -import dev.isxander.controlify.virtualmouse.VirtualMouseHandler; -import net.minecraft.client.InputType; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.gui.components.AbstractWidget; -import net.minecraft.client.gui.components.events.GuiEventListener; -import net.minecraft.client.gui.components.tabs.Tab; -import net.minecraft.client.gui.components.tabs.TabNavigationBar; -import net.minecraft.client.gui.navigation.FocusNavigationEvent; -import net.minecraft.client.gui.navigation.ScreenDirection; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.client.input.KeyEvent; -import net.minecraft.client.resources.sounds.SimpleSoundInstance; -import net.minecraft.network.chat.Component; -import net.minecraft.sounds.SoundEvents; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.function.Supplier; - -public class ScreenProcessor { - public final T screen; - protected final HoldRepeatHelper holdRepeatHelper; - protected static final Minecraft minecraft = Minecraft.getInstance(); - - private final List eventListeners = new ArrayList<>(); - - public ScreenProcessor(T screen) { - this.screen = screen; - if (screen instanceof ScreenControllerEventListener eventListener) { - eventListeners.add(eventListener); - } - this.holdRepeatHelper = createHoldRepeatHelper(); - } - - public void onControllerUpdate(ControllerEntity controller) { - Controlify.instance().virtualMouseHandler().handleControllerInput(controller); - - if (!Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled()) { - if (!handleComponentNavOverride(controller)) - handleComponentNavigation(controller); - - if (!handleComponentButtonOverride(controller)) - handleButtons(controller); - } else { - handleScreenVMouse(controller, Controlify.instance().virtualMouseHandler()); - } - - handleTabNavigation(controller); - - eventListeners.forEach(listener -> listener.onControllerInput(controller)); - } - - public void render(ControllerEntity controller, GuiGraphicsExtractor graphics, float tickDelta) { - var vmouse = Controlify.instance().virtualMouseHandler(); - this.render(controller, graphics, tickDelta, vmouse.isVirtualMouseEnabled() ? Optional.of(vmouse) : Optional.empty()); - } - - public void onInputModeChanged(InputMode mode) { - switch (mode) { - case KEYBOARD_MOUSE -> { - boolean shouldKeepFocus = getFocusTree().stream() - .anyMatch(component -> ComponentProcessorProvider.provide(component).shouldKeepFocusOnKeyboardMode(this)); - - if (!shouldKeepFocus) { - ((ScreenAccessor) screen).controlify$invokeClearFocus(); - } - } - case CONTROLLER, MIXED -> { - if (!Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled()) { - ((ScreenAccessor) screen).invokeSetInitialFocus(); - } - } - } - } - - protected void handleComponentNavigation(ControllerEntity controller) { - if (screen.getFocused() == null) { - ((ScreenAccessor) screen).invokeSetInitialFocus(); - } - - var focuses = List.copyOf(getFocusTree()); - - boolean repeatEventAvailable = holdRepeatHelper.canNavigate(); - - InputComponent input = controller.input().orElseThrow(); - ControllerStateView state = input.stateNow(); - ControllerStateView prevState = input.stateThen(); - - InputBinding guiNaviRight = ControlifyBindings.GUI_NAVI_RIGHT.on(controller); - InputBinding guiNaviLeft = ControlifyBindings.GUI_NAVI_LEFT.on(controller); - InputBinding guiNaviUp = ControlifyBindings.GUI_NAVI_UP.on(controller); - InputBinding guiNaviDown = ControlifyBindings.GUI_NAVI_DOWN.on(controller); - - Supplier navigationFunc = null; - if (guiNaviRight.digitalNow() && (repeatEventAvailable || !guiNaviRight.digitalPrev())) { - navigationFunc = this.createScreenNavigationFunc(ScreenDirection.RIGHT); - - if (!guiNaviRight.digitalPrev()) - holdRepeatHelper.reset(); - } else if (guiNaviLeft.digitalNow() && (repeatEventAvailable || !guiNaviLeft.digitalPrev())) { - navigationFunc = this.createScreenNavigationFunc(ScreenDirection.LEFT); - - if (!guiNaviLeft.digitalPrev()) - holdRepeatHelper.reset(); - } else if (guiNaviUp.digitalNow() && (repeatEventAvailable || !guiNaviUp.digitalPrev())) { - navigationFunc = this.createScreenNavigationFunc(ScreenDirection.UP); - - if (!guiNaviUp.digitalPrev()) - holdRepeatHelper.reset(); - } else if (guiNaviDown.digitalNow() && (repeatEventAvailable || !guiNaviDown.digitalPrev())) { - navigationFunc = this.createScreenNavigationFunc(ScreenDirection.DOWN); - - if (!guiNaviDown.digitalPrev()) - holdRepeatHelper.reset(); - } else if (state.isButtonDown(GamepadInputs.DPAD_RIGHT_BUTTON) && (repeatEventAvailable || !prevState.isButtonDown(GamepadInputs.DPAD_RIGHT_BUTTON))) { - navigationFunc = this.createScreenNavigationFunc(ScreenDirection.RIGHT); - - if (!prevState.isButtonDown(GamepadInputs.DPAD_RIGHT_BUTTON)) - holdRepeatHelper.reset(); - } else if (state.isButtonDown(GamepadInputs.DPAD_LEFT_BUTTON) && (repeatEventAvailable || !prevState.isButtonDown(GamepadInputs.DPAD_LEFT_BUTTON))) { - navigationFunc = this.createScreenNavigationFunc(ScreenDirection.LEFT); - - if (!prevState.isButtonDown(GamepadInputs.DPAD_LEFT_BUTTON)) - holdRepeatHelper.reset(); - } else if (state.isButtonDown(GamepadInputs.DPAD_UP_BUTTON) && (repeatEventAvailable || !prevState.isButtonDown(GamepadInputs.DPAD_UP_BUTTON))) { - navigationFunc = this.createScreenNavigationFunc(ScreenDirection.UP); - - if (!prevState.isButtonDown(GamepadInputs.DPAD_UP_BUTTON)) - holdRepeatHelper.reset(); - } else if (state.isButtonDown(GamepadInputs.DPAD_DOWN_BUTTON) && (repeatEventAvailable || !prevState.isButtonDown(GamepadInputs.DPAD_DOWN_BUTTON))) { - navigationFunc = this.createScreenNavigationFunc(ScreenDirection.DOWN); - - if (!prevState.isButtonDown(GamepadInputs.DPAD_DOWN_BUTTON)) - holdRepeatHelper.reset(); - } - - if (navigationFunc != null) { - // ScrollableLayout only reveals newly focused children for keyboard-style navigation. - // Set this at the point of navigation as the controller input mode may not have been - // established yet when a newly opened screen built its widgets. - minecraft.setLastInputType(InputType.KEYBOARD_ARROW); - if (navigationFunc.get()) { - holdRepeatHelper.onNavigate(); - - controller.input().ifPresent(InputComponent::notifyGuiPressOutputsOfNavigate); - - if (Controlify.instance().config().getSettings().globalSettings().extraUiSounds) { - playFocusChangeSound(); - } - controller.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE)); - - var newFocusTree = getFocusTree(); - while (!newFocusTree.isEmpty() && !focuses.contains(newFocusTree.peek())) { - ComponentProcessorProvider.provide(newFocusTree.poll()).onFocusGained(this, controller); - } - } - } - } - - protected @Nullable Supplier createScreenNavigationFunc(ScreenDirection direction) { - var event = new FocusNavigationEvent.ArrowNavigation(direction); - var path = screen.nextFocusPath(event); - if (path == null) { - return null; - } - - return () -> { - ((ScreenAccessor) screen).controlify$invokeChangeFocus(path); - return true; - }; - } - - protected void handleButtons(ControllerEntity controller) { - boolean vmouseEnabled = Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled(); - InputComponent input = controller.input().orElseThrow(); - boolean touchpadPressed = input.stateNow().isButtonDown(GamepadInputs.TOUCHPAD_1_BUTTON); - boolean prevTouchpadPressed = input.stateThen().isButtonDown(GamepadInputs.TOUCHPAD_1_BUTTON); - - if (ControlifyBindings.GUI_PRESS.on(controller).guiPressed().get() || (vmouseEnabled && touchpadPressed && !prevTouchpadPressed)) { - if (!this.tryOpenKeyboard(controller, screen.getFocused())) { - screen.keyPressed(new KeyEvent(InputConstants.KEY_RETURN, 0, 0)); - } - } - if (screen.shouldCloseOnEsc() && ControlifyBindings.GUI_BACK.on(controller).guiPressed().get()) { - playClackSound(); - screen.onClose(); - } - } - - protected void handleScreenVMouse(ControllerEntity controller, VirtualMouseHandler vmouse) { - Minecraft.getInstance().setLastInputType(InputType.MOUSE); - } - - protected boolean handleComponentButtonOverride(ControllerEntity controller) { - var focusTree = getFocusTree(); - while (!focusTree.isEmpty()) { - var focused = focusTree.poll(); - var processor = ComponentProcessorProvider.provide(focused); - if (processor.overrideControllerButtons(this, controller)) return true; - } - - return false; - } - - protected boolean handleComponentNavOverride(ControllerEntity controller) { - var focusTree = getFocusTree(); - while (!focusTree.isEmpty()) { - var focused = focusTree.poll(); - var processor = ComponentProcessorProvider.provide(focused); - if (processor.overrideControllerNavigation(this, controller)) return true; - } - return false; - } - - protected void handleTabNavigation(ControllerEntity controller) { - var nextTab = ControlifyBindings.GUI_NEXT_TAB.on(controller).justPressed(); - var prevTab = ControlifyBindings.GUI_PREV_TAB.on(controller).justPressed(); - - if (nextTab || prevTab) { - screen.children().stream() - .filter(child -> child instanceof TabNavigationBar) - .map(TabNavigationBar.class::cast) - .findAny() - .ifPresent(navBar -> { - var accessor = (TabNavigationBarAccessor) navBar; - List tabs = accessor.controlify$getTabs(); - int currentIndex = tabs.indexOf(accessor.controlify$getTabManager().getCurrentTab()); - - int newIndex = currentIndex + (prevTab ? -1 : 1); - if (newIndex < 0) newIndex = tabs.size() - 1; - if (newIndex >= tabs.size()) newIndex = 0; - - navBar.selectTab(newIndex, true); - onTabChanged(controller); - }); - } - } - - protected void onTabChanged(ControllerEntity controller) { - - } - - public void onWidgetRebuild() { - if (Controlify.instance().currentInputMode().isController() && !Controlify.instance().virtualMouseHandler().isVirtualMouseEnabled()) { - // setting this input type causes the vanilla screen to call setInitialFocus - // doing it again would cause the second widget to be focused instead of the first - minecraft.setLastInputType(InputType.KEYBOARD_ARROW); - } - } - - public void onVirtualMouseToggled(boolean enabled) { - if (enabled) { - ((ScreenAccessor) screen).controlify$invokeClearFocus(); - } else { - ((ScreenAccessor) screen).invokeSetInitialFocus(); - } - } - - protected void render(ControllerEntity controller, GuiGraphicsExtractor graphics, float tickDelta, Optional vmouse) { - - } - - public VirtualMouseBehaviour virtualMouseBehaviour() { - return VirtualMouseBehaviour.DEFAULT; - } - - public void addEventListener(ScreenControllerEventListener listener) { - eventListeners.add(listener); - } - - public boolean tryOpenKeyboard(ControllerEntity controller, @Nullable GuiEventListener element) { - var componentProcessor = ComponentProcessorProvider.provide(element); - ComponentKeyboardBehaviour behaviour = componentProcessor.getKeyboardBehaviour(this, controller); - - switch (behaviour) { - case ComponentKeyboardBehaviour.DoNothing() -> { - // prevent pressing enter on select when handled - return true; - } - case ComponentKeyboardBehaviour.Undefined() -> { - return false; - } - case ComponentKeyboardBehaviour.Handled( - KeyboardLayoutWithId layout, - InputTarget inputTarget, - KeyboardOverlayScreen.KeyboardPositioner positioner - ) -> { - if (controller.settings().generic.keyboard.showOnScreenKeyboard) { - MinecraftUtil.setScreen(new KeyboardOverlayScreen(screen, layout, inputTarget, positioner)); - } - return true; - } - } - } - - protected HoldRepeatHelper createHoldRepeatHelper() { - return new HoldRepeatHelper(10, 3); - } - - protected Queue getFocusTree() { - if (screen.getFocused() == null) return new ArrayDeque<>(); - - var tree = new ArrayDeque(); - var focused = screen.getFocused(); - tree.add(focused); - while (focused instanceof CustomFocus customFocus) { - focused = customFocus.getCustomFocus(); - - if (focused != null) - tree.addFirst(focused); - } - - return tree; - } - - protected final Optional getWidget(Component message) { - return screen.children().stream() - .filter(child -> child instanceof AbstractWidget) - .map(AbstractWidget.class::cast) - .filter(widget -> widget.getMessage().equals(message)) - .findAny(); - } - - protected final Optional getWidget(String translationKey) { - String translatedName = Component.translatable(translationKey).getString(); - - return screen.children().stream() - .filter(child -> child instanceof AbstractWidget) - .map(AbstractWidget.class::cast) - .filter(widget -> widget.getMessage().getString().equals(translatedName)) - .findAny(); - } - - public static void playClackSound() { - minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0F)); - } - - public static void playFocusChangeSound() { - minecraft.getSoundManager().play(SimpleSoundInstance.forUI(ControlifyClientSounds.SCREEN_FOCUS_CHANGE.get(), 1.0F)); - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorFactory.java b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorFactory.java deleted file mode 100644 index e828ad843..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorFactory.java +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop; - -import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; -import net.minecraft.client.gui.screens.Screen; - -import java.util.Map; -import java.util.function.Function; - -@SuppressWarnings("unchecked") -public final class ScreenProcessorFactory { - private static final Map, Factory> factories = new Object2ObjectOpenHashMap<>(); - - private ScreenProcessorFactory() { - } - - public static ScreenProcessor createForScreen(T screen) { - Class screenClass = screen.getClass(); - while (!factories.containsKey(screenClass) && screenClass != Screen.class) { - screenClass = (Class) screenClass.getSuperclass(); - } - - return ((Factory) factories.getOrDefault(screenClass, ScreenProcessorFactory::createDefault)).apply(screen); - } - - public static void registerProvider(Class screenClass, Factory factory) { - factories.put(screenClass, factory); - } - - private static ScreenProcessor createDefault(T screen) { - return new ScreenProcessor<>(screen); - } - - public interface Factory extends Function> { - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorProvider.java b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorProvider.java deleted file mode 100644 index d472e2e09..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessorProvider.java +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop; - -import net.minecraft.client.gui.screens.Screen; -import org.jetbrains.annotations.NotNull; - - -public interface ScreenProcessorProvider { - ScreenProcessor screenProcessor(); - - static ScreenProcessor provide(@NotNull Screen screen) { - return ((ScreenProcessorProvider) screen).screenProcessor(); - } - - static void registerProvider(@NotNull Class screenClass, @NotNull ScreenProcessorFactory.Factory factory) { - ScreenProcessorFactory.registerProvider(screenClass, factory); - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/compat/AbstractSliderComponentProcessor.java b/src/main/java/dev/isxander/controlify/screenop/compat/AbstractSliderComponentProcessor.java deleted file mode 100644 index 00ba690c4..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/compat/AbstractSliderComponentProcessor.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop.compat; - -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ComponentProcessor; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.utils.HoldRepeatHelper; - -/** - * A component processor that handles incrementing and decrementing a slider. - * This uses {@link dev.isxander.controlify.bindings.ControllerBindings#CYCLE_OPT_FORWARD} and {@link dev.isxander.controlify.bindings.ControllerBindings#CYCLE_OPT_BACKWARD} to increment and decrement the slider. - */ -public abstract class AbstractSliderComponentProcessor implements ComponentProcessor { - private final HoldRepeatHelper holdRepeatHelper = new HoldRepeatHelper(15, 3); - - @Override - public boolean overrideControllerNavigation(ScreenProcessor screen, ControllerEntity controller) { - var left = ControlifyBindings.GUI_SECONDARY_NAVI_LEFT.on(controller).digitalNow(); - var leftPrev = ControlifyBindings.GUI_SECONDARY_NAVI_LEFT.on(controller).digitalPrev(); - var right = ControlifyBindings.GUI_SECONDARY_NAVI_RIGHT.on(controller).digitalNow(); - var rightPrev = ControlifyBindings.GUI_SECONDARY_NAVI_RIGHT.on(controller).digitalPrev(); - - boolean repeatEventAvailable = holdRepeatHelper.canNavigate(); - - if (left && (repeatEventAvailable || !leftPrev)) { - incrementSlider(true); - - if (!leftPrev) - holdRepeatHelper.reset(); - } else if (right && (repeatEventAvailable || !rightPrev)) { - incrementSlider(false); - - if (!rightPrev) - holdRepeatHelper.reset(); - } else { - return false; - } - - holdRepeatHelper.onNavigate(); - - return true; - } - - protected abstract void incrementSlider(boolean reverse); -} diff --git a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractButtonComponentProcessor.java b/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractButtonComponentProcessor.java deleted file mode 100644 index b7aeb4503..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractButtonComponentProcessor.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop.compat.vanilla; - -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.haptic.HapticEffects; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.ComponentProcessor; -import net.minecraft.client.Minecraft; -import net.minecraft.client.gui.components.AbstractButton; -import com.mojang.blaze3d.platform.InputConstants; -import net.minecraft.client.input.KeyEvent; - -public class AbstractButtonComponentProcessor implements ComponentProcessor { - private final AbstractButton button; - - public AbstractButtonComponentProcessor(AbstractButton button) { - this.button = button; - } - - @Override - public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) { - if (ControlifyBindings.GUI_PRESS.on(controller).guiPressed().get()) { - controller.hdHaptics().ifPresent(hh -> hh.playHaptic(HapticEffects.NAVIGATE)); - button.playDownSound(Minecraft.getInstance().getSoundManager()); - button.onPress(new KeyEvent(InputConstants.KEY_RETURN, 0, 0)); - return true; - } - - return false; - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractContainerScreenProcessor.java b/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractContainerScreenProcessor.java deleted file mode 100644 index 4753536c1..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractContainerScreenProcessor.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop.compat.vanilla; - -import dev.isxander.controlify.InputMode; -import dev.isxander.controlify.api.ControlifyApi; -import dev.isxander.controlify.api.contextual.ContainerContext; -import dev.isxander.controlify.api.contextual.GuideInstance; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.contextual.ContextualDomains; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.controller.haptic.HapticEffects; -import dev.isxander.controlify.gui.guide.GuideRenderer; -import dev.isxander.controlify.mixins.feature.guide.screen.AbstractContainerScreenAccessor; -import dev.isxander.controlify.mixins.feature.screenop.ScreenAccessor; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.virtualmouse.VirtualMouseBehaviour; -import dev.isxander.controlify.virtualmouse.VirtualMouseHandler; -import net.minecraft.client.gui.components.Renderable; -import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; - -import net.minecraft.world.inventory.Slot; - -import java.util.List; -import java.util.function.Predicate; -import java.util.function.Supplier; -import net.minecraft.world.inventory.ContainerInput; - -public class AbstractContainerScreenProcessor> extends ScreenProcessor { - - private final GuideInstance guideInstance; - private GuideRenderer.Renderable guideRenderable; - - private final Supplier hoveredSlot; - private final ClickSlotFunction clickSlotFunction; - - private final Predicate doItemSlotActions; - - public AbstractContainerScreenProcessor( - T screen, - Supplier hoveredSlot, - ClickSlotFunction clickSlotFunction, - Predicate doItemSlotActions - ) { - super(screen); - this.hoveredSlot = hoveredSlot; - this.clickSlotFunction = clickSlotFunction; - this.doItemSlotActions = doItemSlotActions; - this.guideInstance = ContextualDomains.INSTANCE.container().createGuideInstance(minecraft.font); - } - - @Override - protected void handleScreenVMouse(ControllerEntity controller, VirtualMouseHandler vmouse) { - var accessor = (AbstractContainerScreenAccessor) screen; - - var ctx = new ContainerContext( - minecraft.player, - hoveredSlot.get(), - screen.getMenu().getCarried(), - accessor.controlify$invokeHasClickedOutside( - vmouse.getCurrentX(1f), - vmouse.getCurrentY(1f), - accessor.controlify$getLeftPos(), - accessor.controlify$getTopPos() - ), - controller, - controller.settings().generic.guide.verbosity - ); - this.guideInstance.update(ctx); - - Slot hoveredSlot = this.hoveredSlot.get(); - if (hoveredSlot != null) { - if (hoveredSlot.hasItem()) { - if (doItemSlotActions.test(controller)) { - return; - } - } - - if (ControlifyBindings.INV_SELECT.on(controller).justPressed()) { - clickSlotFunction.clickSlot(hoveredSlot, hoveredSlot.index, 0, ContainerInput.PICKUP); - hapticNavigate(); - } - - if (ControlifyBindings.INV_QUICK_MOVE.on(controller).justPressed()) { - clickSlotFunction.clickSlot(hoveredSlot, hoveredSlot.index, 0, ContainerInput.QUICK_MOVE); - hapticNavigate(); - } - - if (ControlifyBindings.INV_TAKE_HALF.on(controller).justPressed()) { - clickSlotFunction.clickSlot(hoveredSlot, hoveredSlot.index, 1, ContainerInput.PICKUP); - hapticNavigate(); - } - -// if (ControlifyBindings.SWAP_HANDS.on(controller).justPressed()) { -// clickSlotFunction.clickSlot(hoveredSlot, hoveredSlot.index, 40, ContainerInput.SWAP); -// hapticNavigate(); -// } - } else { - vmouse.handleCompatibilityBinds(controller); - } - - if (!screen.getMenu().getCarried().isEmpty()) { - if (ControlifyBindings.DROP_INVENTORY.on(controller).justPressed()) { - clickSlotFunction.clickSlot(null, -999, 0, ContainerInput.PICKUP); - hapticNavigate(); - } - } - } - - @Override - public void onWidgetRebuild() { - this.guideRenderable = (GuideRenderer.Renderable) guideInstance.renderable(true, false); - - if (ControlifyApi.get().currentInputMode().isController()) { - setRenderGuide(true); - } - } - - @Override - public void onInputModeChanged(InputMode mode) { - setRenderGuide(mode.isController()); - } - - private void setRenderGuide(boolean render) { - ControlifyApi.get().getCurrentController() - .ifPresent(c -> this.guideRenderable.setGuiScale(c.settings().generic.guide.screenGuiScale)); - - render &= ControlifyApi.get().getCurrentController().map(c -> c.settings().generic.guide.showScreenGuides).orElse(false); - - List renderables = ((ScreenAccessor) screen).controlify$getRenderables(); - - if (render) { - renderables.add(this.guideRenderable); - } else if (this.guideRenderable != null) { - renderables.remove(this.guideRenderable); - } - } - - public void onHoveredSlotChanged(Slot newSlot, Slot oldSlot) { - if (ControlifyApi.get().currentInputMode().isController()) { - hapticNavigate(); - } - } - - private void hapticNavigate() { - ControlifyApi.get().getCurrentController().flatMap(ControllerEntity::hdHaptics).ifPresent(hh -> { - hh.playHaptic(HapticEffects.NAVIGATE); - }); - } - - @Override - public VirtualMouseBehaviour virtualMouseBehaviour() { - return VirtualMouseBehaviour.CURSOR_SCROLL; - } - - @FunctionalInterface - public interface ClickSlotFunction { - void clickSlot( - Slot slot, - int slotId, - int button, - ContainerInput containerInput - ); - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractSignEditScreenProcessor.java b/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractSignEditScreenProcessor.java deleted file mode 100644 index 5ec4835ce..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AbstractSignEditScreenProcessor.java +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop.compat.vanilla; - -import dev.isxander.controlify.Controlify; -import dev.isxander.controlify.InputMode; -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.keyboard.CommonKeyboardHints; -import dev.isxander.controlify.screenop.keyboard.KeyboardWidget; -import dev.isxander.controlify.utils.LazyComponentDims; -import dev.isxander.controlify.utils.PrecomputedComponentDims; -import dev.isxander.controlify.virtualmouse.VirtualMouseHandler; -import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.gui.components.AbstractButton; -import net.minecraft.client.gui.screens.inventory.AbstractSignEditScreen; -import net.minecraft.network.chat.CommonComponents; -import net.minecraft.network.chat.Component; -import net.minecraft.util.FormattedCharSequence; -import net.minecraft.world.level.block.entity.SignBlockEntity; - -import java.util.List; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Supplier; - -public class AbstractSignEditScreenProcessor extends ScreenProcessor { - - private static final Component signLineHint = Component.translatable("controlify.hint.sign_line_change", - ControlifyBindings.GUI_SECONDARY_NAVI_UP.inputGlyph(), - ControlifyBindings.GUI_SECONDARY_NAVI_DOWN.inputGlyph()); - - private final Consumer moveCursorFunc; - private final Supplier signSupplier; - private final Supplier keyboardWidgetSupplier; - - private List> signLineHintLines; - - public AbstractSignEditScreenProcessor( - AbstractSignEditScreen screen, - Consumer moveCursorFunc, - Supplier signSupplier, - Supplier keyboardWidgetSupplier - ) { - super(screen); - this.moveCursorFunc = moveCursorFunc; - this.signSupplier = signSupplier; - this.keyboardWidgetSupplier = keyboardWidgetSupplier; - } - - @Override - protected void handleButtons(ControllerEntity controller) { - super.handleButtons(controller); - - var config = controller.settings().generic; - - // move cursor down a line - if (ControlifyBindings.GUI_SECONDARY_NAVI_DOWN.on(controller).justPressed()) { - this.moveCursorFunc.accept(1); - - if (config.keyboard.hintSignLine && config.guide.showScreenGuides) { - config.keyboard.hintSignLine = false; - Controlify.instance().config().saveSafely(); - } - - playFocusChangeSound(); - } - - // move cursor up a line - if (ControlifyBindings.GUI_SECONDARY_NAVI_UP.on(controller).justPressed()) { - this.moveCursorFunc.accept(-1); - - if (config.keyboard.hintSignLine && config.guide.showScreenGuides) { - config.keyboard.hintSignLine = false; - Controlify.instance().config().saveSafely(); - } - - playFocusChangeSound(); - } - } - - @Override - protected void render(ControllerEntity controller, GuiGraphicsExtractor graphics, float tickDelta, Optional vmouse) { - var config = controller.settings().generic; - KeyboardWidget keyboardWidget = this.keyboardWidgetSupplier.get(); - if (keyboardWidget != null && config.guide.showScreenGuides) { - if (config.keyboard.hintCursor) { - LazyComponentDims hint = CommonKeyboardHints.TEXT_CURSOR; - - int x = keyboardWidget.getRight() - hint.getWidth() - 2; - int y = keyboardWidget.getY() - hint.getHeight(); - - graphics.text(minecraft.font, hint.getComponent(), x, y, 0xFFFFFFFF, true); - } - - if (config.keyboard.hintSignLine && this.signLineHintLines != null) { - int y = 4; - for (PrecomputedComponentDims line : this.signLineHintLines) { - int lineWidth = line.width(); - int lineHeight = line.height(); - FormattedCharSequence lineText = line.component(); - - graphics.text(minecraft.font, lineText, this.screen.width - 1 - lineWidth, y, 0xFFFFFFFF, true); - - y += minecraft.font.lineHeight; - } - } - } - } - - @Override - public void onWidgetRebuild() { - super.onWidgetRebuild(); - - if (Controlify.instance().currentInputMode() == InputMode.MIXED) { - holdRepeatHelper.clearDelay(); - } - - getWidget(CommonComponents.GUI_DONE).ifPresent(doneButton -> - ButtonGuideApi.addGuideToButton( - (AbstractButton) doneButton, - ControlifyBindings.GUI_BACK, - ButtonGuidePredicate.always() - )); - - // compute hint wrapping - SignBlockEntity sign = this.signSupplier.get(); - int signRight = (screen.width / 2) + (sign.getMaxTextLineWidth() / 2); - int maxLineWidth = screen.width - signRight; - this.signLineHintLines = minecraft.font.split(signLineHint, maxLineWidth).stream() - .map(cs -> PrecomputedComponentDims.of(cs, minecraft.font)) - .toList(); - } -} diff --git a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AddServerLikeScreenProcessor.java b/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AddServerLikeScreenProcessor.java deleted file mode 100644 index f0eeda1c9..000000000 --- a/src/main/java/dev/isxander/controlify/screenop/compat/vanilla/AddServerLikeScreenProcessor.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright (C) 2026 isXander - * This file is part of Controlify. - * - * SPDX-License-Identifier: LGPL-3.0-or-later - */ -package dev.isxander.controlify.screenop.compat.vanilla; - -import dev.isxander.controlify.api.buttonguide.ButtonGuideApi; -import dev.isxander.controlify.api.buttonguide.ButtonGuidePredicate; -import dev.isxander.controlify.bindings.ControlifyBindings; -import dev.isxander.controlify.controller.ControllerEntity; -import dev.isxander.controlify.screenop.ComponentProcessorProvider; -import dev.isxander.controlify.screenop.ScreenProcessor; -import dev.isxander.controlify.screenop.keyboard.KeyboardLayouts; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.client.gui.screens.Screen; - -import java.util.function.Supplier; - -public class AddServerLikeScreenProcessor extends ScreenProcessor { - - private final Supplier ipEditBoxSupplier; - private final Supplier