diff --git a/build.gradle.kts b/build.gradle.kts
index 6370c2691..3cb95948f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -153,6 +153,7 @@ manifests {
"controlify:controller_type",
"controlify:default_binds",
"controlify:trigger_effect",
+ "controlify:radial_icons",
)
))
diff --git a/docs/developers/bindings-api.mdx b/docs/developers/bindings-api.mdx
index 9447b8367..1c2062184 100644
--- a/docs/developers/bindings-api.mdx
+++ b/docs/developers/bindings-api.mdx
@@ -7,56 +7,84 @@ _Learn how to register new controller bindings._
## Registering a custom input binding
- You should register bindings inside of the Controlify init entrypoint.
- Read more in [Controlify Entrypoint](controlify-entrypoint#using-the-entrypoint).
+ Register bindings inside the Controlify pre-init entrypoint. Read more in
+ [Controlify Entrypoint](controlify-entrypoint#using-the-entrypoint).
-Controlify allows users to configure different buttons on their controllers to actions in-game.
-You may want your own mod's actions to be able to be invoked from the controller too.
-
-To register a controller binding, you must use the `ControllerBindingsApi.`
+Controlify bindings let users assign controller inputs to actions in your mod. Register a binding through
+the `ControlifyBindApi` supplied by the pre-init context:
```java
-private BindingSupplier action1Binding;
+private InputBindingSupplier action1Binding;
@Override
-public void onControlifyPreInit(PreInitContext ctx) {
- action1Binding = ctx.bindings().registerBinding(
- builder -> builder
- .id("mymod", "action1") // the id of the binding, this should be unique to your mod
- .category(Component.translatable("mymod.binding.category")) // the category of the binding, this is used to group bindings together in the settings
- .allowedContexts(BindContext.IN_GAME) // a context is where the binding can be used, you can use multiple contexts
- .radialCandiate(RadialIcons.getEffect/getIcon(...)) // if you want to allow your binding to be used in the radial menu
+public void onControlifyPreInit(PreInitContext context) {
+ action1Binding = context.bindings().registerBinding(builder -> builder
+ .id("mymod", "action1")
+ .category(Component.translatable("mymod.binding.category"))
+ .allowedContexts(BindContext.IN_GAME)
);
}
```
-To add a name and description to the binding, you need to define the language keys
-`controlify.binding..` and `controlify.binding...desc`
-respectively, alternatively, you can set `.name(Component)` and `.description(Component)`.
+The ID and category are required. By default, the name and optional description use these language keys:
+
+- `controlify.binding..`
+- `controlify.binding...desc`
+
+You can instead provide `.name(Component)` and `.description(Component)` explicitly.
+
+Registration returns an `InputBindingSupplier`. Once a controller exists, use
+`action1Binding.on(controller)` to obtain that controller's `InputBinding`. If you registered the binding
+with a controller filter, use `onOrNull(controller)` when the controller may not match it.
+
+## Integrating an existing key mapping
-Registering the binding provides you with a `BindingSupplier`, where you can then access the
-binding with `action1Binding.on(controller);`
+On both Fabric and NeoForge, Controlify automatically converts otherwise unhandled modded `KeyMapping`s into
+controller bindings. These generated bindings are also radial menu candidates and use a book as their
+fallback radial icon. Mods that do not want a Java integration can still assign icons to these bindings; see
+[Icons for automatically generated bindings](../resource-packs/radial-icons#icons-for-automatically-generated-bindings).
-Controlify automatically converts your existed modded `KeyMapping`s to controller bindings,
-but relying on this behaviour if you are going to explicitly support Controlify is not recommended.
-You can stop this conversion with the following...
+When explicitly registering a Controlify binding for an existing key mapping, correlate it so Controlify
+does not also create an automatic binding. Use `keyEmulation` if pressing the controller binding should press
+the key mapping:
```java
-@Override
-public void onControlifyPreInit(PreInitContext ctx) {
- ctx.bindings().exclude(MyMod.myKeyMapping);
+action1Binding = context.bindings().registerBinding(builder -> builder
+ .id("mymod", "action1")
+ .category(Component.translatable("mymod.binding.category"))
+ .allowedContexts(BindContext.IN_GAME)
+ .keyEmulation(MyMod.ACTION_1_KEY)
+);
+```
+
+`keyEmulation` automatically adds the key correlation. If your code handles the Controlify binding directly
+and should not emulate the key, use `.addKeyCorrelation(MyMod.ACTION_1_KEY)` instead.
+
+## Adding the binding to the radial menu
+
+Radial candidates and icons are data-driven. Add the binding ID to
+`assets/controlify/radial_icons.json` in your mod's resources:
+
+```json
+{
+ "mymod:action1": {
+ "model": "mymod:radial/action1"
+ }
}
```
+The Java bindings API does not register radial icons or mark radial candidates. See
+[Radial Menu Icons](../resource-packs/radial-icons) for model and texture icon definitions, file locations,
+and resource-pack override behavior.
+
## Defining a default binding
-You may have noticed that in the above code, we did not define a default binding, such as the A button.
-This is because default bindings are data-driven and defined by resource packs.
-If you want to define a default binding, you can do so by creating a JSON file in your mod's resources.
+Default controller inputs are data-driven. To assign the south face button to the example binding for every
+controller, create:
-```json !!tabs (Default for all controllers) assets/controlify/controllers/default_bind/default.json
+```json !!tabs assets/controlify/controllers/default_bind/default.json
{
"defaults": {
"mymod:action1": {
@@ -67,35 +95,36 @@ If you want to define a default binding, you can do so by creating a JSON file i
```
-This will set the default binding for `mymod:action1` to the south face button on all controllers.
-You can also define defaults for specific controller namespaces by creating a file in
-`assets//controllers/default_bind/.json`.
-
-For more information on how to define default bindings, see the [Default Binds](../resource-packs/default-binds) page.
+Defaults for a specific controller namespace belong at
+`assets//controllers/default_bind/.json`. For the full format, see
+[Default Binds](../resource-packs/default-binds).
## Using the binding
-Once you have access to a binding through `bindingSupplier.on(controller)`,
-you can access many properties of the binding:
+Once you have an `InputBinding`, its commonly used state accessors include:
-| Property | Description |
-|----------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
-| `inputGlyph()` | Returns the input glyph for the binding, which can be used in text. |
-| `digitalNow()` | Returns true if the binding is pressed this tick. |
-| `digitalPrev()` | Returns true if the binding was pressed in the previous tick. |
-| `analogueNow()` | Gets a float value between 0 - 1 which describes its analogue state. |
-| `analoguePrev()` | Gets a float value between 0 - 1 which describes its analogue state in the previous tick. |
-| `justPressed()` | Returns if the binding is pressed this tick, if it returns true, the state is consumed and will return false for the remaining tick. |
-| `justReleased()` | Returns if the binding was just released this tick, if it returns true, the state is consumed and will return false for the remaining tick. |
-| `justTapped()` | Returns if the binding was just tapped this tick, if it returns true, the state is consumed and will return false for the remaining tick. |
-| `guiPressed().get()` | Returns true if the binding is pressed this tick in the GUI context. |
+| Property | Description |
+|----------------------|--------------------------------------------------------------------------------------------------|
+| `inputGlyph()` | Returns the input glyph for this binding and controller. |
+| `digitalNow()` | Returns whether the binding is pressed this tick. |
+| `digitalPrev()` | Returns whether the binding was pressed during the previous tick. |
+| `analogueNow()` | Returns the current analogue value, from 0 to 1. |
+| `analoguePrev()` | Returns the analogue value from the previous tick. |
+| `justPressed()` | Returns whether the binding became pressed this tick. |
+| `justReleased()` | Returns whether the binding became released this tick. |
+| `justTapped()` | Returns whether the binding was pressed and then released. |
+| `guiPressed().get()` | Handles a GUI press that completes only if focus remains on the widget where the press began. |
-There are more properties available inside of `InputBinding` which you can look at in the sources,
-but the above are the most notable that you will use the most.
+See `InputBinding` in the API sources for advanced state access and custom outputs.
## Rendering binding glyphs
-There is nothing special about rendering glyphs for controller bindings, as Controlify utilises custom fonts.
+Controller glyphs are text components backed by Controlify's input font. With a
+`GuiGraphicsExtractor`, render a binding glyph like any other component:
+
+```java
+graphics.text(Minecraft.getInstance().font, binding.inputGlyph(), x, y, -1);
+```
-This means you can use the glyphs within localised text, or just render it with
-`graphics.drawString(myBinding.inputGlyph(), x, y, -1);`
+`InputBindingSupplier.inputGlyph()` can be used when you want the glyph for the currently selected
+controller without first resolving a particular controller's binding.
diff --git a/docs/resource-packs/_meta.json b/docs/resource-packs/_meta.json
index 1fdf22c0b..ce3a6421d 100644
--- a/docs/resource-packs/_meta.json
+++ b/docs/resource-packs/_meta.json
@@ -4,5 +4,6 @@
"input-glyphs.mdx": "Input Glyphs",
"guides.mdx": "Button Guides",
"keyboard-layouts.mdx": "Keyboard Layouts",
+ "radial-icons.mdx": "Radial Menu Icons",
"adaptive-trigger-effects.mdx": "Adaptive Trigger Effects"
}
diff --git a/docs/resource-packs/radial-icons.mdx b/docs/resource-packs/radial-icons.mdx
new file mode 100644
index 000000000..d3b29d665
--- /dev/null
+++ b/docs/resource-packs/radial-icons.mdx
@@ -0,0 +1,156 @@
+---
+title: Radial Menu Icons
+---
+
+Radial menu candidates and their icons are defined by resource packs. Mods can provide an icon for one of
+their bindings by including resources in their own JAR; no Java call into Controlify is required.
+
+## Creating the mapping
+
+Create `assets/controlify/radial_icons.json`. The keys are binding IDs and each value defines either an item
+model or a GUI sprite:
+
+
+```json !!tabs assets/controlify/radial_icons.json
+{
+ "example:open_backpack": {
+ "model": "example:backpack"
+ },
+ "example:open_overlay": {
+ "model": "example:radial/open_overlay"
+ },
+ "example:mute_microphone": {
+ "texture": "example:radial/mute_microphone"
+ }
+}
+```
+
+
+An entry must contain exactly one of `model` or `texture`. Adding a binding to this file also makes it
+available in the radial menu editor. The binding itself must still be registered by Controlify or another
+mod.
+
+## Model icons
+
+The value of `model` is an **item model definition ID**. You do not normally need to create a model
+specifically for the radial menu. If an action already has an item that represents it, prefer reusing that
+item's existing model.
+
+For example, if the example mod registers a backpack item whose item model definition is
+`assets/example/items/backpack.json`, its Open Backpack binding can use that model directly:
+
+```json
+{
+ "example:open_backpack": {
+ "model": "example:backpack"
+ }
+}
+```
+
+Vanilla item models can be reused in the same way, such as `minecraft:chest` or `minecraft:spyglass`.
+
+### Creating an icon-only model
+
+When no existing item represents the action, create an item model definition specifically for the radial
+icon. For `"model": "example:radial/open_overlay"`, Minecraft loads:
+
+`assets/example/items/radial/open_overlay.json`
+
+For example, the item model definition can point at a regular generated model:
+
+
+```json !!tabs assets/example/items/radial/open_overlay.json
+{
+ "model": {
+ "type": "minecraft:model",
+ "model": "example:item/radial/open_overlay"
+ }
+}
+```
+
+```json !!tabs assets/example/models/item/radial/open_overlay.json
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "example:item/radial/open_overlay"
+ }
+}
+```
+
+
+The texture used by that example belongs at
+`assets/example/textures/item/radial/open_overlay.png`.
+
+The file under `items/` does not need to belong to a registered item. Minecraft discovers and bakes every
+item model definition supplied by loaded resource packs, so a mod can create definitions used only by
+Controlify's radial menu.
+
+Radial model icons are rendered in the GUI display context without the state of a real item stack. Prefer a
+plain model for these icons; item model conditions that depend on components or other stack data will not
+have that data available.
+
+## Texture icons
+
+The value of `texture` is a sprite ID in Minecraft's GUI atlas, rather than a direct path to an arbitrary PNG.
+The default GUI atlas discovers files below `textures/gui/sprites`, so
+`"texture": "example:radial/mute_microphone"` resolves to:
+
+`assets/example/textures/gui/sprites/radial/mute_microphone.png`
+
+Sprites added to the GUI atlas by other atlas sources can also be referenced. For example, Minecraft's
+Jump Boost effect sprite is `minecraft:mob_effect/jump_boost`.
+
+Texture icons are drawn into an 18 by 18 pixel area, matching the potion-effect icons used by Controlify.
+
+## Icons for automatically generated bindings
+
+Mods do not need a full Controlify integration to assign radial icons. On both Fabric and NeoForge,
+Controlify automatically creates controller bindings for modded key mappings that have not been correlated
+with an explicitly registered Controlify binding. These generated bindings are always radial candidates and
+use a book model when no mapping is provided.
+
+The generated binding ID uses the key mapping name returned by `KeyMapping#getName()`:
+
+`controlify_modded:`
+
+Equivalent Java code for the generated ID is:
+
+```java
+String path = keyMapping.getName()
+ .toLowerCase()
+ .replaceAll("[^a-z0-9/._-]", "_")
+ .trim();
+Identifier id = Identifier.fromNamespaceAndPath("controlify_modded", path);
+```
+
+Controlify calculates the path by lowercasing the name, replacing every character outside
+`a-z`, `0-9`, `/`, `.`, `_`, and `-` with `_`, and then trimming it. This uses the key mapping's stable name
+or translation key, not its translated text. For example:
+
+`key.example.open_backpack` becomes `controlify_modded:key.example.open_backpack`.
+
+The mod can assign the generated binding an icon by including only this resource:
+
+
+```json !!tabs assets/controlify/radial_icons.json
+{
+ "controlify_modded:key.example.open_backpack": {
+ "model": "example:backpack"
+ }
+}
+```
+
+
+If the mod later registers and correlates an explicit Controlify binding for that key mapping, the automatic
+binding is not created. Its radial icon entry should then use the explicit binding's ID instead.
+
+## Resource-pack priority
+
+Controlify merges every loaded `assets/controlify/radial_icons.json` by binding ID. If the same binding is
+defined twice, the definition from the higher-priority resource pack—the pack shown earlier in the active
+resource-pack list—wins. Definitions for other bindings remain in place, so a pack can override a single icon
+without copying the entire built-in file.
+
+If one file contains invalid JSON, an invalid identifier, or an entry with both/neither icon fields,
+Controlify logs the error and skips that entire file layer. Definitions from other resource packs continue
+to load.
diff --git a/src/main/java/dev/isxander/controlify/Controlify.java b/src/main/java/dev/isxander/controlify/Controlify.java
index b8ba45e1f..eafc6c9d8 100644
--- a/src/main/java/dev/isxander/controlify/Controlify.java
+++ b/src/main/java/dev/isxander/controlify/Controlify.java
@@ -15,6 +15,7 @@
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;
@@ -151,6 +152,7 @@ public void preInitialiseControlify() {
PlatformClientUtil.registerAssetReloadListener(controllerTypeManager);
PlatformClientUtil.registerAssetReloadListener(keyboardLayoutManager);
PlatformClientUtil.registerAssetReloadListener(triggerEffectRegistry);
+ PlatformClientUtil.registerAssetReloadListener(RadialIconManager.INSTANCE);
PlatformClientUtil.registerAssetReloadListener(GuideDomains.IN_GAME);
PlatformClientUtil.registerAssetReloadListener(GuideDomains.CONTAINER);
diff --git a/src/main/java/dev/isxander/controlify/api/bind/ControlifyBindApi.java b/src/main/java/dev/isxander/controlify/api/bind/ControlifyBindApi.java
index 3ef45deeb..5fc639f63 100644
--- a/src/main/java/dev/isxander/controlify/api/bind/ControlifyBindApi.java
+++ b/src/main/java/dev/isxander/controlify/api/bind/ControlifyBindApi.java
@@ -63,14 +63,6 @@ static ControlifyBindApi get() {
*/
List getKeyCorrelation(KeyMapping key);
- /**
- * Registers a new radial icon that can be used in the radial menu.
- *
- * @param id the id of the icon
- * @param icon the icon object
- */
- void registerRadialIcon(Identifier id, RadialIcon icon);
-
/**
* Registers a new bind context that can be used to determine when a binding is active.
*
diff --git a/src/main/java/dev/isxander/controlify/api/bind/InputBinding.java b/src/main/java/dev/isxander/controlify/api/bind/InputBinding.java
index 2b8ed5388..7f13af8cc 100644
--- a/src/main/java/dev/isxander/controlify/api/bind/InputBinding.java
+++ b/src/main/java/dev/isxander/controlify/api/bind/InputBinding.java
@@ -19,7 +19,6 @@
import net.minecraft.resources.Identifier;
import org.jetbrains.annotations.ApiStatus;
-import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
@@ -145,14 +144,6 @@ default boolean isUnbound() {
*/
Set contexts();
- /**
- * Returns the radial icon's ID.
- * If the binding does not have a radial icon, this will return an empty optional.
- * If empty, consider this binding as not being a radial candidate. It can never be added to the radial menu.
- * @return the radial icon's ID or an empty optional
- */
- Optional radialIcon();
-
/**
* Equivalent to calling
*
diff --git a/src/main/java/dev/isxander/controlify/api/bind/InputBindingBuilder.java b/src/main/java/dev/isxander/controlify/api/bind/InputBindingBuilder.java
index c5e46fdcb..07478116a 100644
--- a/src/main/java/dev/isxander/controlify/api/bind/InputBindingBuilder.java
+++ b/src/main/java/dev/isxander/controlify/api/bind/InputBindingBuilder.java
@@ -122,15 +122,6 @@ public interface InputBindingBuilder {
*/
InputBindingBuilder allowedContexts(@NotNull BindContext @Nullable... contexts);
- /**
- * Specifies an icon that can be used in the radial menu.
- * Also allows for this binding to be selected in the radial menu editor screen.
- *
- * @param icon the ID of the icon
- * @return this builder
- */
- InputBindingBuilder radialCandidate(@Nullable Identifier icon);
-
/**
* 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.
diff --git a/src/main/java/dev/isxander/controlify/api/bind/RadialIcon.java b/src/main/java/dev/isxander/controlify/api/bind/RadialIcon.java
index 689bb11f4..101c41f9a 100644
--- a/src/main/java/dev/isxander/controlify/api/bind/RadialIcon.java
+++ b/src/main/java/dev/isxander/controlify/api/bind/RadialIcon.java
@@ -6,11 +6,69 @@
*/
package dev.isxander.controlify.api.bind;
-import net.minecraft.client.gui.GuiGraphicsExtractor;
+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;
-@FunctionalInterface
-public interface RadialIcon {
- RadialIcon EMPTY = (graphics, x, y, tickDelta) -> {};
+import java.util.Optional;
- void draw(GuiGraphicsExtractor graphics, int x, int y, float tickDelta);
+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/bindings/ControlifyBindApiImpl.java b/src/main/java/dev/isxander/controlify/bindings/ControlifyBindApiImpl.java
index 89417962a..111686670 100644
--- a/src/main/java/dev/isxander/controlify/bindings/ControlifyBindApiImpl.java
+++ b/src/main/java/dev/isxander/controlify/bindings/ControlifyBindApiImpl.java
@@ -9,7 +9,6 @@
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.api.bind.RadialIcon;
import dev.isxander.controlify.bindings.output.KeyMappingEmulationOutput;
import dev.isxander.controlify.controller.ControllerEntity;
import net.minecraft.client.KeyMapping;
@@ -61,13 +60,6 @@ public List getKeyCorrelation(KeyMapping key) {
return Optional.ofNullable(this.keyMappingCorrelations.get(key)).orElse(List.of());
}
- @Override
- public void registerRadialIcon(Identifier id, RadialIcon icon) {
- checkLocked();
-
- RadialIcons.registerIcon(id, icon);
- }
-
@Override
public void registerBindContext(BindContext context) {
BindContext.CONTEXTS.put(context.id(), context);
diff --git a/src/main/java/dev/isxander/controlify/bindings/ControlifyBindings.java b/src/main/java/dev/isxander/controlify/bindings/ControlifyBindings.java
index a1a96bcff..877dcf352 100644
--- a/src/main/java/dev/isxander/controlify/bindings/ControlifyBindings.java
+++ b/src/main/java/dev/isxander/controlify/bindings/ControlifyBindings.java
@@ -16,13 +16,13 @@
import net.minecraft.client.Options;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
-import net.minecraft.world.effect.MobEffects;
-import net.minecraft.world.item.Items;
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 = Component.translatable("key.categories.movement");
@@ -80,8 +80,7 @@ public final class ControlifyBindings {
public static final InputBindingSupplier JUMP = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "jump")
.category(MOVEMENT_CATEGORY)
- .allowedContexts(BindContext.IN_GAME)
- .radialCandidate(RadialIcons.getEffect(MobEffects.JUMP_BOOST)));
+ .allowedContexts(BindContext.IN_GAME));
public static final InputBindingSupplier SPRINT = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "sprint")
.category(MOVEMENT_CATEGORY)
@@ -106,28 +105,23 @@ public final class ControlifyBindings {
public static final InputBindingSupplier DROP_INGAME = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "drop")
.category(GAMEPLAY_CATEGORY)
- .allowedContexts(BindContext.IN_GAME)
- .radialCandidate(RadialIcons.getItem(Items.BARRIER)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.TNT)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.STRUCTURE_VOID)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.PAINTING)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.BONE)));
+ .allowedContexts(BindContext.IN_GAME));
public static final InputBindingSupplier NEXT_SLOT = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "next_slot")
.category(GAMEPLAY_CATEGORY)
@@ -148,8 +142,7 @@ public final class ControlifyBindings {
public static final InputBindingSupplier INVENTORY = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "inventory")
.category(INVENTORY_CATEGORY)
- .allowedContexts(BindContext.IN_GAME)
- .radialCandidate(RadialIcons.getItem(Items.CHEST)));
+ .allowedContexts(BindContext.IN_GAME));
public static final InputBindingSupplier INV_SELECT = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "inv_select")
.category(INVENTORY_CATEGORY)
@@ -170,13 +163,11 @@ public final class ControlifyBindings {
public static final InputBindingSupplier PICK_BLOCK = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "pick_block")
.category(CREATIVE_CATEGORY)
- .allowedContexts(BindContext.IN_GAME)
- .radialCandidate(RadialIcons.getItem(Items.STICK)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.DEBUG_STICK)));
+ .allowedContexts(BindContext.IN_GAME));
public static final InputBindingSupplier HOTBAR_LOAD_RADIAL = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "hotbar_load_radial")
.category(CREATIVE_CATEGORY)
@@ -190,23 +181,19 @@ public final class ControlifyBindings {
.id("controlify", "open_chat")
.category(MISC_CATEGORY)
.allowedContexts(BindContext.IN_GAME)
- .radialCandidate(RadialIcons.getItem(Items.WRITABLE_BOOK))
.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)
- .radialCandidate(RadialIcons.getEffect(MobEffects.INVISIBILITY)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.PLAYER_HEAD)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.SPYGLASS)));
+ .allowedContexts(BindContext.IN_GAME));
public static final InputBindingSupplier DEBUG_RADIAL = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "debug_radial")
@@ -215,23 +202,54 @@ public final class ControlifyBindings {
public static final InputBindingSupplier TOGGLE_DEBUG_MENU = ControlifyBindApi.get().registerBinding(builder -> builder
.id("controlify", "toggle_debug_menu")
.category(DEBUG_CATEGORY)
- .allowedContexts(BindContext.IN_GAME)
- .radialCandidate(RadialIcons.getItem(Items.DEBUG_STICK)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.DEBUG_STICK)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.DEBUG_STICK)));
+ .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)
- .radialCandidate(RadialIcons.getItem(Items.DEBUG_STICK)));
+ .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)
@@ -408,14 +426,13 @@ public static void registerModdedBindings() {
.replaceAll("[^a-z0-9/._-]", "_")
.trim();
- var identifier = Identifier.fromNamespaceAndPath("fabric-key-binding-api-v1", idPath);
+ 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())
- .radialCandidate(RadialIcons.getModLoaderIcon())
.allowedContexts(BindContext.IN_GAME)
.keyEmulation(keyMapping));
diff --git a/src/main/java/dev/isxander/controlify/bindings/InputBindingBuilderImpl.java b/src/main/java/dev/isxander/controlify/bindings/InputBindingBuilderImpl.java
index edf76f2b3..34f9eba7f 100644
--- a/src/main/java/dev/isxander/controlify/bindings/InputBindingBuilderImpl.java
+++ b/src/main/java/dev/isxander/controlify/bindings/InputBindingBuilderImpl.java
@@ -31,8 +31,6 @@ public class InputBindingBuilderImpl implements InputBindingBuilder {
private @Nullable Component customName, customDescription;
private @Nullable Input defaultInput;
private final Set allowedContexts = new HashSet<>();
- private @Nullable Identifier radialCandidate;
-
private final Set keyCorrelations = new HashSet<>();
private KeyMapping keyEmulation = null;
private Function keyEmulationToggle = null;
@@ -93,14 +91,6 @@ public InputBindingBuilder allowedContexts(@NotNull BindContext @Nullable ... co
return this;
}
- @Override
- public InputBindingBuilder radialCandidate(@Nullable Identifier icon) {
- checkLocked();
-
- this.radialCandidate = icon;
- return this;
- }
-
@Override
public InputBindingBuilder addKeyCorrelation(@NotNull KeyMapping keyMapping) {
checkLocked();
@@ -159,8 +149,7 @@ public InputBindingImpl build(@Nullable ControllerEntity controller) {
description,
category,
defaultSupplier,
- allowedContexts,
- radialCandidate
+ allowedContexts
);
}
diff --git a/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java b/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java
index 21729061b..cbb781121 100644
--- a/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java
+++ b/src/main/java/dev/isxander/controlify/bindings/InputBindingImpl.java
@@ -31,8 +31,6 @@ public class InputBindingImpl implements InputBinding {
private Input fallbackBoundInput;
private final Supplier defaultBindSupplier;
private final Set contexts;
- private final @Nullable Identifier radialIcon;
-
private final @Nullable InputComponent inputComponent;
private final Identifier controllerType;
@@ -63,8 +61,7 @@ public InputBindingImpl(
Component description,
Component category,
Supplier defaultBindSupplier,
- Set contexts,
- @Nullable Identifier radialIcon
+ Set contexts
) {
this.inputComponent = inputComponent;
this.controllerType = controllerType;
@@ -76,7 +73,6 @@ public InputBindingImpl(
this.fallbackBoundInput = defaultBindSupplier.get();
this.defaultBindSupplier = defaultBindSupplier;
this.contexts = contexts;
- this.radialIcon = radialIcon;
this.borrowedAccesses = new HashSet<>();
this.digitalOutputs = new HashMap<>();
@@ -229,11 +225,6 @@ public Set contexts() {
return this.contexts;
}
- @Override
- public Optional radialIcon() {
- return Optional.ofNullable(this.radialIcon);
- }
-
@Override
public float analogueNow() {
return this.analogueNow.get();
diff --git a/src/main/java/dev/isxander/controlify/bindings/RadialIconExtractor.java b/src/main/java/dev/isxander/controlify/bindings/RadialIconExtractor.java
new file mode 100644
index 000000000..3dc6c0580
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/bindings/RadialIconExtractor.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.bindings;
+
+import dev.isxander.controlify.api.bind.RadialIcon;
+import dev.isxander.controlify.mixins.feature.bind.GuiGraphicsExtractorAccessor;
+import dev.isxander.controlify.mixins.feature.bind.ItemStackRenderStateAccessor;
+import dev.isxander.controlify.platform.client.PlatformClientUtil;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.renderer.RenderPipelines;
+import net.minecraft.client.renderer.item.TrackingItemStackRenderState;
+import net.minecraft.client.renderer.state.gui.GuiItemRenderState;
+import net.minecraft.resources.Identifier;
+import net.minecraft.world.item.ItemDisplayContext;
+import net.minecraft.world.item.ItemStack;
+import org.joml.Matrix3x2f;
+
+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.Texture(Identifier texture) -> extractTexture(graphics, texture, x, y);
+ }
+
+ if (icon.overlay() != null) {
+ graphics.text(Minecraft.getInstance().font, icon.overlay(), x, y, -1);
+ }
+ }
+
+ private static void extractModel(GuiGraphicsExtractor graphics, Identifier model, int x, int y) {
+ Minecraft minecraft = Minecraft.getInstance();
+ TrackingItemStackRenderState renderState = new TrackingItemStackRenderState();
+ ItemDisplayContext displayContext = ItemDisplayContext.GUI;
+
+ ((ItemStackRenderStateAccessor) renderState).controlify$setDisplayContext(displayContext);
+ renderState.setOversizedInGui(minecraft.getModelManager().getItemProperties(model).oversizedInGui());
+ minecraft.getModelManager().getItemModel(model).update(
+ renderState,
+ ItemStack.EMPTY,
+ minecraft.getItemModelResolver(),
+ displayContext,
+ minecraft.level,
+ minecraft.player,
+ 0
+ );
+
+ if (!renderState.isEmpty()) {
+ ((GuiGraphicsExtractorAccessor) graphics).controlify$getGuiRenderState().addItem(
+ new GuiItemRenderState(
+ new Matrix3x2f(graphics.pose()),
+ renderState,
+ x,
+ y,
+ PlatformClientUtil.peekScissorStack(graphics)
+ )
+ );
+ }
+ }
+
+ private static void extractTexture(GuiGraphicsExtractor graphics, Identifier texture, int x, int y) {
+ var pose = graphics.pose().pushMatrix();
+ pose.translate(x, y);
+ pose.scale(0.88f, 0.88f);
+ graphics.blitSprite(RenderPipelines.GUI_TEXTURED, texture, 0, 0, 18, 18);
+ pose.popMatrix();
+ }
+
+ private RadialIconExtractor() {
+ }
+}
diff --git a/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java b/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java
new file mode 100644
index 000000000..b9a668573
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.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.bindings;
+
+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 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 final class RadialIconManager implements SimpleControlifyReloadListener