From a7fb02a79cc43e916742f420b66bf05f2e47770a Mon Sep 17 00:00:00 2001 From: Xander Date: Wed, 5 Aug 2026 20:30:34 +0100 Subject: [PATCH 1/2] Better Radial Menu --- build.gradle.kts | 1 + docs/developers/bindings-api.mdx | 133 ++-- docs/resource-packs/_meta.json | 1 + docs/resource-packs/radial-icons.mdx | 156 ++++ .../dev/isxander/controlify/Controlify.java | 2 + .../api/bind/ControlifyBindApi.java | 8 - .../controlify/api/bind/InputBinding.java | 9 - .../api/bind/InputBindingBuilder.java | 9 - .../controlify/api/bind/RadialIcon.java | 68 +- .../bindings/ControlifyBindApiImpl.java | 8 - .../bindings/ControlifyBindings.java | 91 ++- .../bindings/InputBindingBuilderImpl.java | 13 +- .../controlify/bindings/InputBindingImpl.java | 11 +- .../bindings/RadialIconExtractor.java | 76 ++ .../bindings/RadialIconManager.java | 87 +++ .../controlify/bindings/RadialIcons.java | 130 ---- .../SimpleVoiceChatCompat.java | 14 - .../config/dto/dfu/ControlifyDataFixer.java | 6 +- .../config/dto/dfu/ControlifySchemas.java | 6 + .../dto/dfu/fixes/RadialMenuActionsFix.java | 64 ++ .../config/dto/profile/InputConfig.java | 8 +- .../settings/profile/InputSettings.java | 3 - .../screen/ControllerConfigScreenFactory.java | 10 +- .../controlify/gui/screen/RadialItems.java | 273 ++----- .../gui/screen/RadialMenuEditModel.java | 78 ++ .../gui/screen/RadialMenuEditScreen.java | 684 ++++++++++++++++++ .../gui/screen/RadialMenuPages.java | 27 + .../gui/screen/RadialMenuScreen.java | 557 +++++--------- .../controlify/ingame/InGameInputHandler.java | 61 +- .../bind/GuiGraphicsExtractorAccessor.java | 18 + .../bind/ItemStackRenderStateAccessor.java | 18 + .../controlify/screenop/ScreenProcessor.java | 4 + .../controllers/default_config.json | 13 +- .../assets/controlify/lang/en_us.json | 12 + .../assets/controlify/radial_icons.json | 29 + src/main/resources/controlify.mixins.json | 2 + 36 files changed, 1794 insertions(+), 896 deletions(-) create mode 100644 docs/resource-packs/radial-icons.mdx create mode 100644 src/main/java/dev/isxander/controlify/bindings/RadialIconExtractor.java create mode 100644 src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java delete mode 100644 src/main/java/dev/isxander/controlify/bindings/RadialIcons.java create mode 100644 src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/RadialMenuActionsFix.java create mode 100644 src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditModel.java create mode 100644 src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditScreen.java create mode 100644 src/main/java/dev/isxander/controlify/gui/screen/RadialMenuPages.java create mode 100644 src/main/java/dev/isxander/controlify/mixins/feature/bind/GuiGraphicsExtractorAccessor.java create mode 100644 src/main/java/dev/isxander/controlify/mixins/feature/bind/ItemStackRenderStateAccessor.java create mode 100644 src/main/resources/assets/controlify/radial_icons.json 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..26ff07d72
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.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.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> {
+	public static final RadialIconManager INSTANCE = new RadialIconManager();
+	public static final Identifier EMPTY = CUtil.rl("empty");
+
+	private static final Identifier DEFINITIONS = CUtil.rl("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();
+
+	public boolean isRadialCandidate(InputBinding binding) {
+		return iconsByBinding.containsKey(binding.id())
+				|| binding.id().getNamespace().equals(ControlifyBindings.MODDED_BIND_NAMESPACE);
+	}
+
+	public RadialIcon getIcon(InputBinding binding) {
+		return iconsByBinding.getOrDefault(binding.id(), FALLBACK);
+	}
+
+	@Override
+	public CompletableFuture> load(ResourceManager manager, Executor executor) {
+		return CompletableFuture.supplyAsync(() -> {
+			Map mappings = new HashMap<>();
+			List resources = manager.getResourceStack(DEFINITIONS);
+
+			// Resource stacks are returned from highest to lowest priority.
+			// Apply them in reverse so higher packs overwrite individual bindings.
+			for (int i = resources.size() - 1; i >= 0; i--) {
+				Resource resource = resources.get(i);
+				try (BufferedReader reader = resource.openAsReader()) {
+					JsonElement json = JsonParser.parseReader(reader);
+					CODEC.parse(JsonOps.INSTANCE, json)
+							.resultOrPartial(error -> CUtil.LOGGER.error(
+									"Failed to parse radial icons from pack '{}': {}",
+									resource.sourcePackId(),
+									error
+							))
+							.ifPresent(mappings::putAll);
+				} catch (Exception e) {
+					CUtil.LOGGER.error("Failed to load radial icons from pack '{}'", resource.sourcePackId(), e);
+				}
+			}
+
+			return Map.copyOf(mappings);
+		}, executor);
+	}
+
+	@Override
+	public CompletableFuture apply(Map data, ResourceManager manager, Executor executor) {
+		return CompletableFuture.runAsync(() -> iconsByBinding = data, executor);
+	}
+
+	@Override
+	public Identifier getReloadId() {
+		return CUtil.rl("radial_icons");
+	}
+
+	private RadialIconManager() {
+	}
+}
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 5546ec6e7..000000000
--- a/src/main/java/dev/isxander/controlify/bindings/RadialIcons.java
+++ /dev/null
@@ -1,130 +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.RadialIcon;
-import dev.isxander.controlify.utils.CUtil;
-import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
-import net.minecraft.client.Minecraft;
-import net.minecraft.client.renderer.RenderPipelines;
-import net.minecraft.core.Holder;
-import net.minecraft.core.registries.BuiltInRegistries;
-import net.minecraft.resources.ResourceKey;
-import net.minecraft.resources.Identifier;
-import net.minecraft.world.effect.MobEffect;
-import net.minecraft.world.item.Item;
-import net.minecraft.world.item.ItemStack;
-import net.minecraft.world.item.Items;
-import org.jetbrains.annotations.NotNull;
-
-import java.util.ArrayDeque;
-import java.util.Map;
-import java.util.Queue;
-
-public final class RadialIcons {
-	private static final Minecraft minecraft = Minecraft.getInstance();
-
-	public static final Identifier EMPTY = CUtil.rl("empty");
-	private static final Identifier FABRIC_ICON = Identifier.fromNamespaceAndPath("fabric-resource-loader-v0", "icon.png");
-
-	private static Map icons = null;
-	private static Queue deferredRegistrations = new ArrayDeque<>();
-
-	public static Map getIcons() {
-		if (icons == null) {
-			icons = registerIcons();
-			deferredRegistrations.forEach(Runnable::run);
-			deferredRegistrations = null;
-		}
-		return icons;
-	}
-
-	public static void registerIcon(Identifier location, RadialIcon icon) {
-		if (icons == null) {
-			deferredRegistrations.add(() -> registerIcon(location, icon));
-			return;
-		}
-		icons.put(location, icon);
-	}
-
-	public static Identifier getItem(Item item) {
-		return BuiltInRegistries.ITEM.getKey(item).withPrefix("item/");
-	}
-
-	public static Identifier getEffect(Holder effect) {
-		return BuiltInRegistries.MOB_EFFECT.getKey(effect.value()).withPrefix("effect/");
-	}
-
-	private static void addItems(Map map) {
-		BuiltInRegistries.ITEM.entrySet().forEach(entry -> {
-			ResourceKey key = entry.getKey();
-			ItemStack stack = entry.getValue().getDefaultInstance();
-
-			map.put(key.identifier().withPrefix("item/"), (graphics, x, y, a) -> {
-				graphics.item(stack, x, y);
-			});
-		});
-	}
-
-	private static void addPotionEffects(Map map) {
-		BuiltInRegistries.MOB_EFFECT.entrySet().forEach(entry -> {
-			ResourceKey key = entry.getKey();
-
-			Holder effect = BuiltInRegistries.MOB_EFFECT.wrapAsHolder(entry.getValue());
-
-			//? if >=26.2 {
-			Identifier sprite = net.minecraft.client.gui.Hud.getMobEffectSprite(effect);
-			//?} else {
-			/*Identifier sprite = net.minecraft.client.gui.Gui.getMobEffectSprite(effect);
-			*///?}
-
-			map.put(key.identifier().withPrefix("effect/"), (graphics, x, y, a) -> {
-				var pose = graphics.pose().pushMatrix();
-				pose.translate(x, y);
-				pose.scale(0.88f, 0.88f);
-
-				graphics.blitSprite(
-						RenderPipelines.GUI_TEXTURED,
-						sprite,
-						0, 0,
-						18, 18
-				);
-
-				pose.popMatrix();
-			});
-		});
-	}
-
-	private static Map registerIcons() {
-		Map map = new Object2ObjectOpenHashMap<>();
-		final Identifier modLoaderIcon = getModLoaderIcon();
-
-		map.put(EMPTY, (graphics, x, y, tickDelta) -> {});
-		map.put(modLoaderIcon, (graphics, x, y, tickDelta) -> {
-			var pose = graphics.pose().pushMatrix();
-			pose.translate(x, y);
-			pose.scale(0.5f, 0.5f);
-			graphics.blit(
-					RenderPipelines.GUI_TEXTURED,
-					modLoaderIcon,
-					0, 0,
-					0, 0,
-					32, 32,
-					32, 32
-			);
-			pose.popMatrix();
-		});
-		addItems(map);
-		addPotionEffects(map);
-
-		return map;
-	}
-
-	public static @NotNull Identifier getModLoaderIcon() {
-		return getItem(Items.BOOK);
-	}
-}
diff --git a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SimpleVoiceChatCompat.java b/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SimpleVoiceChatCompat.java
index 7dc2898df..12d0f5de2 100644
--- a/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SimpleVoiceChatCompat.java
+++ b/src/main/java/dev/isxander/controlify/compatibility/simplevoicechat/SimpleVoiceChatCompat.java
@@ -16,10 +16,8 @@
 import dev.isxander.controlify.compatibility.simplevoicechat.mixins.KeyEventsAccessor;
 import dev.isxander.controlify.controller.ControllerEntity;
 import dev.isxander.controlify.utils.MinecraftUtil;
-import net.minecraft.client.renderer.RenderPipelines;
 import net.minecraft.network.chat.CommonComponents;
 import net.minecraft.network.chat.Component;
-import net.minecraft.resources.Identifier;
 
 public class SimpleVoiceChatCompat {
 	private static InputBindingSupplier pttHoldSupplier, pttToggleSupplier;
@@ -28,10 +26,6 @@ public class SimpleVoiceChatCompat {
 	private static boolean pttDown, whisperDown;
 
 	public static void init() {
-		Identifier muteIcon = registerIcon16x(Identifier.fromNamespaceAndPath("voicechat", "textures/icons/microphone_off.png"));
-		Identifier pttIcon = registerIcon16x(Identifier.fromNamespaceAndPath("voicechat", "textures/icons/microphone.png"));
-		Identifier whisperIcon = registerIcon16x(Identifier.fromNamespaceAndPath("voicechat", "textures/icons/microphone_whisper.png"));
-
 		Component category = Component.translatable("key.categories.voicechat");
 		pttHoldSupplier = ControlifyBindApi.get().registerBinding(builder -> builder
 				.id("voicechat", "ptt_hold")
@@ -40,7 +34,6 @@ public static void init() {
 		pttToggleSupplier = ControlifyBindApi.get().registerBinding(builder -> builder
 				.id("voicechat", "ptt_toggle")
 				.category(category)
-				.radialCandidate(pttIcon)
 				.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")))
@@ -51,12 +44,10 @@ public static void init() {
 				.name(Component.translatable("key.whisper").append(CommonComponents.SPACE).append(Component.translatable("controlify.compat.svc.toggle")))
 				.id("voicechat", "whisper_toggle")
 				.category(category)
-				.radialCandidate(whisperIcon)
 				.addKeyCorrelation(KeyEvents.KEY_WHISPER));
 		ControlifyBindApi.get().registerBinding(builder -> builder
 				.id("voicechat", "mute_microphone")
 				.category(category)
-				.radialCandidate(muteIcon)
 				.keyEmulation(KeyEvents.KEY_MUTE));
 
 		ControlifyEvents.ACTIVE_CONTROLLER_TICKED.register(event -> {
@@ -110,10 +101,5 @@ private static void checkConnected() {
 		}
 	}
 
-	private static Identifier registerIcon16x(Identifier location) {
-		ControlifyBindApi.get().registerRadialIcon(location, ((graphics, x, y, tickDelta) ->
-				graphics.blit(RenderPipelines.GUI_TEXTURED, location, x, y, 0, 0, 16, 16, 16, 16)));
-		return location;
-	}
 }
 //?}
diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java
index 385eb7171..6e5db6ebe 100644
--- a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java
+++ b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java
@@ -13,7 +13,7 @@
 import dev.isxander.controlify.config.settings.profile.ProfileSettings;
 
 public final class ControlifyDataFixer {
-	public static final int CURRENT_VERSION = 7;
+	public static final int CURRENT_VERSION = 8;
 
 	private static final DataFixer FIXER = createFixer();
 
@@ -30,6 +30,7 @@ private static DataFixer createFixer() {
 		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);
 
 		var globalDefaults = GlobalSettings.defaults();
 		var profileDefaults = ProfileSettings.createDefault();
@@ -47,6 +48,9 @@ private static DataFixer createFixer() {
 		// v7
 		builder.addFixer(new GuideGuiScaleFix(v7, profileDefaults));
 
+		// v8
+		builder.addFixer(new RadialMenuActionsFix(v8));
+
 		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/config/dto/dfu/ControlifySchemas.java
index 52e988659..173c5b123 100644
--- a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java
+++ b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifySchemas.java
@@ -82,4 +82,10 @@ public V7(int versionKey, Schema parent) {
 			super(versionKey, parent);
 		}
 	}
+
+	public static class V8 extends Schema {
+		public V8(int versionKey, Schema parent) {
+			super(versionKey, parent);
+		}
+	}
 }
diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/RadialMenuActionsFix.java b/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/RadialMenuActionsFix.java
new file mode 100644
index 000000000..f9cd727ba
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/RadialMenuActionsFix.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2026 isXander
+ * This file is part of Controlify.
+ *
+ * SPDX-License-Identifier: LGPL-3.0-or-later
+ */
+package dev.isxander.controlify.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 java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+
+public final class RadialMenuActionsFix extends DataFix {
+	private static final List DEBUG_ACTIONS = List.of(
+			"controlify:toggle_debug_menu",
+			"controlify:toggle_debug_menu_fps",
+			"controlify:toggle_debug_menu_net",
+			"controlify:toggle_debug_menu_prof",
+			"controlify:debug_reload_chunks",
+			"controlify:debug_toggle_chunk_borders",
+			"controlify:debug_toggle_advanced_tooltips",
+			"controlify:debug_toggle_entity_hitboxes",
+			"controlify:debug_reload_resource_packs",
+			"controlify:debug_clear_chat",
+			"controlify:debug_start_stop_profiling"
+	);
+
+	public RadialMenuActionsFix(Schema outputSchema) {
+		super(outputSchema, true);
+	}
+
+	@Override
+	protected TypeRewriteRule makeRule() {
+		var profileType = getInputSchema().getType(ControlifyTypeReferences.PROFILE_CONFIG);
+
+		return fixTypeEverywhereTyped(
+				"Controlify: expand and deduplicate radial menu actions",
+				profileType,
+				typed -> typed.update(DSL.remainderFinder(), this::rewriteProfile)
+		);
+	}
+
+	private  Dynamic rewriteProfile(Dynamic root) {
+		Dynamic input = root.get("input").orElseEmptyMap();
+		Dynamic radialMenu = input.get("radial_menu").orElseEmptyMap();
+		var uniqueActions = new LinkedHashSet();
+		radialMenu.get("actions").asStream()
+				.map(Dynamic::asString)
+				.map(result -> result.result().orElse(null))
+				.filter(Objects::nonNull)
+				.forEach(uniqueActions::add);
+		uniqueActions.addAll(DEBUG_ACTIONS);
+		radialMenu = radialMenu.set("actions", root.createList(uniqueActions.stream().map(root::createString)));
+
+		return root.set("input", input.set("radial_menu", radialMenu));
+	}
+}
diff --git a/src/main/java/dev/isxander/controlify/config/dto/profile/InputConfig.java b/src/main/java/dev/isxander/controlify/config/dto/profile/InputConfig.java
index c75721bf2..dfab3b6ea 100644
--- a/src/main/java/dev/isxander/controlify/config/dto/profile/InputConfig.java
+++ b/src/main/java/dev/isxander/controlify/config/dto/profile/InputConfig.java
@@ -74,14 +74,8 @@ public record RadialMenuConfig(
 			int radialButtonFocusTimeoutTicks
 	) {
 		public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group(
-				Identifier.CODEC.listOf(8, 8).fieldOf("actions").forGetter(RadialMenuConfig::radialActions),
+				Identifier.CODEC.listOf().fieldOf("actions").forGetter(RadialMenuConfig::radialActions),
 				Codec.INT.fieldOf("button_focus_timeout_ticks").forGetter(RadialMenuConfig::radialButtonFocusTimeoutTicks)
 		).apply(instance, RadialMenuConfig::new));
-
-		public RadialMenuConfig {
-			if (radialActions.size() != 8) {
-				throw new IllegalArgumentException("radialActions must have exactly 8 elements");
-			}
-		}
 	}
 }
diff --git a/src/main/java/dev/isxander/controlify/config/settings/profile/InputSettings.java b/src/main/java/dev/isxander/controlify/config/settings/profile/InputSettings.java
index c7547c25e..cf91cf920 100644
--- a/src/main/java/dev/isxander/controlify/config/settings/profile/InputSettings.java
+++ b/src/main/java/dev/isxander/controlify/config/settings/profile/InputSettings.java
@@ -154,9 +154,6 @@ public static class RadialMenuSettings {
 		public int radialButtonFocusTimeoutTicks;
 
 		public RadialMenuSettings(List radialActions, int radialButtonFocusTimeoutTicks) {
-			if (radialActions.size() != 8) {
-				throw new IllegalArgumentException("radialActions must have exactly 8 elements");
-			}
 			this.radialActions = new ArrayList<>(radialActions);
 			this.radialButtonFocusTimeoutTicks = radialButtonFocusTimeoutTicks;
 		}
diff --git a/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java b/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java
index ffa20e622..423f0e0d3 100644
--- a/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java
+++ b/src/main/java/dev/isxander/controlify/gui/screen/ControllerConfigScreenFactory.java
@@ -760,15 +760,7 @@ private Optional makeBindsCategory(
 								.text(Component.translatable("controlify.gui.radial_menu.tooltip"))
 								.build())
 						.action((screen, opt) -> {
-							if (Minecraft.getInstance().level == null) return;
-							MinecraftUtil.setScreen(new RadialMenuScreen(
-									controller.orElseThrow(),
-									null,
-									RadialItems.createBindings(controller.get()),
-									Component.empty(),
-									new RadialItems.BindingEditMode(controller.get()),
-									screen
-							));
+							MinecraftUtil.setScreen(new RadialMenuEditScreen(screen, controller.orElseThrow()));
 						})
 						.text(Component.translatable("controlify.gui.radial_menu.btn_text"))
 						.build())
diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialItems.java b/src/main/java/dev/isxander/controlify/gui/screen/RadialItems.java
index 99fb3962b..995aed0fc 100644
--- a/src/main/java/dev/isxander/controlify/gui/screen/RadialItems.java
+++ b/src/main/java/dev/isxander/controlify/gui/screen/RadialItems.java
@@ -6,43 +6,51 @@
  */
 package dev.isxander.controlify.gui.screen;
 
-import dev.isxander.controlify.api.bind.RadialIcon;
-import dev.isxander.controlify.bindings.RadialIcons;
 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;
-import dev.isxander.controlify.utils.DebugOverlayHelper;
 import net.minecraft.client.Minecraft;
 import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen;
 import net.minecraft.client.player.inventory.Hotbar;
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.core.registries.BuiltInRegistries;
 import net.minecraft.network.chat.Component;
 import net.minecraft.resources.Identifier;
-import net.minecraft.server.permissions.Permissions;
-import net.minecraft.world.effect.MobEffects;
+import net.minecraft.server.commands.GameModeCommand;
 import net.minecraft.world.item.ItemStack;
 import net.minecraft.world.item.Items;
 import net.minecraft.world.level.GameType;
 
-import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.function.Supplier;
 
 public final class RadialItems {
-	public static final RadialMenuScreen.RadialItem EMPTY_ACTION = new RadialItemRecord(Component.empty(), RadialIcon.EMPTY, () -> false, RadialIcons.EMPTY);
+	public static final RadialMenuScreen.RadialItem EMPTY_ACTION = new RadialItemRecord(Component.empty(), RadialIcon.EMPTY, () -> false, RadialIconManager.EMPTY);
 
 	public static RadialMenuScreen.RadialItem[] createBindings(ControllerEntity controller) {
-		RadialMenuScreen.RadialItem[] items = new RadialMenuScreen.RadialItem[8];
+		List actions = controller.settings().input.radialMenu.radialActions;
+		RadialMenuScreen.RadialItem[] items = new RadialMenuScreen.RadialItem[actions.size()];
 
-		for (int i = 0; i < 8; i++) {
-			Identifier bindingId = controller.settings().input.radialMenu.radialActions.get(i);
-
-			items[i] = getItemForBinding(bindingId, controller);
+		for (int i = 0; i < actions.size(); i++) {
+			items[i] = getItemForBinding(actions.get(i), controller);
 		}
 
 		return items;
 	}
 
-	public static RadialMenuScreen.RadialItem[] createGameModes() {
+	public static List createBindingPages(ControllerEntity controller) {
+		RadialMenuScreen.RadialItem[] items = createBindings(controller);
+		return RadialMenuPages.partition(Arrays.asList(items)).stream()
+				.map(page -> page.toArray(RadialMenuScreen.RadialItem[]::new))
+				.toList();
+	}
+
+	public static RadialPage createGameModes() {
 		RadialMenuScreen.RadialItem[] items = new RadialMenuScreen.RadialItem[4];
 
 		items[0] = new GameModeItem(GameType.CREATIVE);
@@ -50,10 +58,13 @@ public static RadialMenuScreen.RadialItem[] createGameModes() {
 		items[2] = new GameModeItem(GameType.ADVENTURE);
 		items[3] = new GameModeItem(GameType.SPECTATOR);
 
-		return items;
+		return new RadialPage(
+			Component.translatable("controlify.binding.controlify.game_mode_switcher"),
+			items
+		);
 	}
 
-	public static RadialMenuScreen.RadialItem[] createHotbarSave() {
+	public static RadialPage createHotbarSave() {
 		Minecraft mc = Minecraft.getInstance();
 		RadialMenuScreen.RadialItem[] items = new RadialMenuScreen.RadialItem[9];
 
@@ -70,10 +81,13 @@ public static RadialMenuScreen.RadialItem[] createHotbarSave() {
 			);
 		}
 
-		return items;
+		return new RadialPage(
+			Component.translatable("controlify.radial.hotbar_save_hint"),
+			items
+		);
 	}
 
-	public static RadialMenuScreen.RadialItem[] createHotbarLoad() {
+	public static RadialPage createHotbarLoad() {
 		Minecraft mc = Minecraft.getInstance();
 		RadialMenuScreen.RadialItem[] items = new RadialMenuScreen.RadialItem[9];
 
@@ -90,7 +104,10 @@ public static RadialMenuScreen.RadialItem[] createHotbarLoad() {
 			);
 		}
 
-		return items;
+		return new RadialPage(
+			Component.translatable("controlify.radial.hotbar_load_hint"),
+			items
+		);
 	}
 
 	public static RadialMenuScreen.RadialItem[] createHotbarItemSelect() {
@@ -101,9 +118,7 @@ public static RadialMenuScreen.RadialItem[] createHotbarItemSelect() {
 			int j = i;
 			items[i] = new RadialItemRecord(
 					Component.translatable("controlify.radial.hotbar", Component.literal(Integer.toString(j + 1))),
-					(graphics, x, y, tickDelta) -> {
-						graphics.item(mc.player.getInventory().getItem(j), x, y);
-					},
+					getIconForItem(mc.player.getInventory().getItem(j)),
 					() -> {
 						mc.player.getInventory().setSelectedSlot(j);
 						return true;
@@ -115,134 +130,23 @@ public static RadialMenuScreen.RadialItem[] createHotbarItemSelect() {
 		return items;
 	}
 
-	public static RadialMenuScreen.RadialItem[] createDebug() {
-		var items = new RadialMenuScreen.RadialItem[]{
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.reload_chunks"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.COMPASS)),
-						() -> {
-							DebugOverlayHelper.reloadChunks();
-							return true;
-						},
-						CUtil.rl("debug/reload_chunks")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.chunk_borders"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.FILLED_MAP)),
-						() -> {
-							DebugOverlayHelper.toggleChunkBorders();
-							return true;
-						},
-						CUtil.rl("debug/chunk_borders")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.advanced_tooltips"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.WRITABLE_BOOK)),
-						() -> {
-							DebugOverlayHelper.toggleAdvancedTooltips();
-							return true;
-						},
-						CUtil.rl("debug/advanced_tooltips")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.entity_hitboxes"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.ZOMBIE_HEAD)),
-						() -> {
-							DebugOverlayHelper.toggleEntityHitboxes();
-							return true;
-						},
-						CUtil.rl("debug/entity_hitboxes")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.reload_packs"),
-						RadialIcons.getIcons().get(
-								//? if >=26.2 {
-								RadialIcons.getItem(Items.DYE.pink())
-								//?} else {
-								/*RadialIcons.getItem(Items.PINK_DYE)
-								*///?}
-						),
-						() -> {
-							DebugOverlayHelper.reloadResourcePacks();
-							return true;
-						},
-						CUtil.rl("debug/reload_packs")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.clear_chat"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.FLINT_AND_STEEL)),
-						() -> {
-							DebugOverlayHelper.clearChat();
-							return true;
-						},
-						CUtil.rl("debug/clear_chat")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.profile"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.STRUCTURE_BLOCK)),
-						() -> {
-							DebugOverlayHelper.startStopProfiling();
-							return true;
-						},
-						CUtil.rl("debug/profile")
-				)
-		};
-
-		boolean overlayEnabled = DebugOverlayHelper.isOverlayEnabled();
-		var overlayItems = !overlayEnabled ? new RadialMenuScreen.RadialItem[]{
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.overlay"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.DEBUG_STICK)),
-						() -> {
-							DebugOverlayHelper.toggleOverlay();
-							return true;
-						},
-						CUtil.rl("debug/overlay")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.overlay_fps"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.CLOCK)),
-						() -> {
-							DebugOverlayHelper.toggleFpsOverlay();
-							return true;
-						},
-						CUtil.rl("debug/fps")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.overlay_net"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.SCULK_SENSOR)),
-						() -> {
-							DebugOverlayHelper.toggleNetworkOverlay();
-							return true;
-						},
-						CUtil.rl("debug/fps")
-				),
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.overlay_prof"),
-						RadialIcons.getIcons().get(RadialIcons.getItem(Items.TARGET)),
-						() -> {
-							DebugOverlayHelper.toggleProfilerOverlay();
-							return true;
-						},
-						CUtil.rl("debug/fps")
-				),
-		} : new RadialMenuScreen.RadialItem[]{
-				new RadialItemRecord(
-						Component.translatable("controlify.radial.debug.hide_overlay"),
-						RadialIcons.getIcons().get(RadialIcons.getEffect(MobEffects.INVISIBILITY)),
-						() -> {
-							DebugOverlayHelper.toggleOverlay();
-							return true;
-						},
-						CUtil.rl("debug/reload_chunks")
-				)
-		};
-
-		RadialMenuScreen.RadialItem[] allItems = new RadialMenuScreen.RadialItem[items.length + overlayItems.length];
-		System.arraycopy(overlayItems, 0, allItems, 0, overlayItems.length);
-		System.arraycopy(items, 0, allItems, overlayItems.length, items.length);
-
-		return allItems;
+	public static RadialMenuScreen.RadialItem[] createDebug(ControllerEntity 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
+		).stream()
+				.map(InputBindingSupplier::bindId)
+				.map(id -> getItemForBinding(id, controller))
+				.toArray(RadialMenuScreen.RadialItem[]::new);
 	}
 
 	private static RadialIcon getIconForHotbar(int hotbarIndex, boolean showNumbers) {
@@ -255,35 +159,34 @@ private static RadialIcon getIconForHotbar(int hotbarIndex, boolean showNumbers)
 			ItemStack stack = hotbarItems.get(i);
 
 			if (!stack.is(Items.AIR)) {
-				return (graphics, x, y, tickDelta) -> {
-					graphics.item(stack, x, y);
-
-					if (showNumbers) {
-						graphics.text(mc.font, Integer.toString(hotbarIndex + 1), x, y, -1);
-					}
-				};
+				RadialIcon icon = getIconForItem(stack);
+				return showNumbers
+						? icon.withOverlay(Component.literal(Integer.toString(hotbarIndex + 1)))
+						: icon;
 			}
 		}
 
-		return (graphics, x, y, tickDelta) -> {
-			if (showNumbers) {
-				graphics.text(mc.font, Integer.toString(hotbarIndex + 1), x, y, -1);
-			}
-		};
+		return showNumbers
+				? RadialIcon.EMPTY.withOverlay(Component.literal(Integer.toString(hotbarIndex + 1)))
+				: RadialIcon.EMPTY;
+	}
+
+	private static RadialIcon getIconForItem(ItemStack stack) {
+		Identifier model = stack.get(DataComponents.ITEM_MODEL);
+		return model != null ? RadialIcon.model(model) : RadialIcon.EMPTY;
 	}
 
 	private static RadialMenuScreen.RadialItem getItemForBinding(Identifier id, ControllerEntity controller) {
 		InputBinding binding = controller.input().orElseThrow().getBinding(id);
 
-		if (binding == null || binding.radialIcon().isEmpty()) {
+		if (binding == null || !RadialIconManager.INSTANCE.isRadialCandidate(binding)) {
 			CUtil.LOGGER.warn("Binding {} does not exist or is not a radial candidate", binding);
 			return EMPTY_ACTION;
 		}
 
-		RadialIcon icon = RadialIcons.getIcons().get(binding.radialIcon().get());
 		return new RadialItemRecord(
 				binding.name(),
-				icon,
+				RadialIconManager.INSTANCE.getIcon(binding),
 				() -> {
 					binding.fakePress();
 					return true;
@@ -292,6 +195,9 @@ private static RadialMenuScreen.RadialItem getItemForBinding(Identifier id, Cont
 		);
 	}
 
+	public record RadialPage(Component name, RadialMenuScreen.RadialItem[] items) {
+	}
+
 	private record RadialItemRecord(Component name, RadialIcon icon, Supplier action, Identifier id) implements RadialMenuScreen.RadialItem {
 		@Override
 		public boolean playAction() {
@@ -318,12 +224,12 @@ public GameModeItem(GameType gameType) {
 			this.gameType = gameType;
 			this.name = gameType.getShortDisplayName();
 			Identifier iconId = switch (gameType) {
-				case CREATIVE -> RadialIcons.getItem(Items.GRASS_BLOCK);
-				case SURVIVAL -> RadialIcons.getItem(Items.IRON_SWORD);
-				case ADVENTURE -> RadialIcons.getItem(Items.MAP);
-				case SPECTATOR -> RadialIcons.getItem(Items.ENDER_EYE);
+				case CREATIVE -> BuiltInRegistries.ITEM.getKey(Items.GRASS_BLOCK);
+				case SURVIVAL -> BuiltInRegistries.ITEM.getKey(Items.IRON_SWORD);
+				case ADVENTURE -> BuiltInRegistries.ITEM.getKey(Items.MAP);
+				case SPECTATOR -> BuiltInRegistries.ITEM.getKey(Items.ENDER_EYE);
 			};
-			this.icon = RadialIcons.getIcons().get(iconId);
+			this.icon = RadialIcon.model(iconId);
 			this.command = switch (gameType) {
 				case CREATIVE -> "gamemode creative";
 				case SURVIVAL -> "gamemode survival";
@@ -346,7 +252,8 @@ public RadialIcon icon() {
 		public boolean playAction() {
 			Minecraft client = Minecraft.getInstance();
 			if (client.gameMode != null && client.player != null) {
-				if (client.player.permissions().hasPermission(Permissions.COMMANDS_GAMEMASTER)
+				if (client.canSwitchGameMode()
+					&& GameModeCommand.PERMISSION_CHECK.check(client.player.permissions())
 					&& client.gameMode.getPlayerMode() != gameType) {
 					client.player.connection.sendCommand(command);
 					return true;
@@ -357,30 +264,4 @@ public boolean playAction() {
 		}
 	}
 
-	public static class BindingEditMode implements RadialMenuScreen.EditMode {
-
-		private final ControllerEntity controller;
-
-		public BindingEditMode(ControllerEntity controller) {
-			this.controller = controller;
-		}
-
-		@Override
-		public void setRadialItem(int index, RadialMenuScreen.RadialItem item) {
-			controller.settings().input.radialMenu.radialActions.set(index, ((RadialItemRecord) item).id());
-		}
-
-		@Override
-		public List getEditCandidates() {
-			List items = new ArrayList<>();
-
-			controller.input().orElseThrow().getAllBindings().forEach(binding -> {
-				binding.radialIcon().ifPresent(icon -> {
-					items.add(new RadialItemRecord(binding.name(), RadialIcons.getIcons().get(icon), () -> false, binding.id()));
-				});
-			});
-
-			return items;
-		}
-	}
 }
diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditModel.java b/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditModel.java
new file mode 100644
index 000000000..c9f81df74
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditModel.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.gui.screen;
+
+import net.minecraft.resources.Identifier;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+
+final class RadialMenuEditModel {
+	private final List actions;
+	private @Nullable Identifier carried;
+
+	RadialMenuEditModel(List actions) {
+		this.actions = new ArrayList<>(new LinkedHashSet<>(actions));
+	}
+
+	List actions() {
+		return List.copyOf(actions);
+	}
+
+	boolean isCarrying() {
+		return carried != null;
+	}
+
+	@Nullable Identifier carried() {
+		return carried;
+	}
+
+	boolean carriedIsEquipped() {
+		return carried != null && actions.contains(carried);
+	}
+
+	void pickUp(Identifier id) {
+		if (carried != null) throw new IllegalStateException("Already carrying a radial action");
+		carried = id;
+	}
+
+	void drop() {
+		carried = null;
+	}
+
+	int unequipCarried() {
+		if (!carriedIsEquipped()) return actions.size();
+		int index = actions.indexOf(carried);
+		actions.remove(index);
+		return index;
+	}
+
+	boolean equipCarried(int index) {
+		if (carried == null || actions.contains(carried)) return false;
+		actions.add(Math.clamp(index, 0, actions.size()), carried);
+		return true;
+	}
+
+	boolean moveCarried(int direction) {
+		if (!carriedIsEquipped() || direction == 0) return false;
+		int oldIndex = actions.indexOf(carried);
+		return moveCarriedTo(oldIndex + Integer.signum(direction));
+	}
+
+	boolean moveCarriedTo(int index) {
+		if (!carriedIsEquipped()) return false;
+		int oldIndex = actions.indexOf(carried);
+		int newIndex = Math.clamp(index, 0, actions.size() - 1);
+		if (oldIndex == newIndex) return false;
+
+		actions.remove(oldIndex);
+		actions.add(newIndex, carried);
+		return true;
+	}
+}
diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditScreen.java b/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditScreen.java
new file mode 100644
index 000000000..c05f80997
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuEditScreen.java
@@ -0,0 +1,684 @@
+/*
+ * 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.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 net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractButton;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.ScrollableLayout;
+import net.minecraft.client.gui.components.events.ContainerEventHandler;
+import net.minecraft.client.gui.layouts.GridLayout;
+import net.minecraft.client.gui.layouts.HeaderAndFooterLayout;
+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.navigation.ScreenDirection;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.input.InputWithModifiers;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.renderer.RenderPipelines;
+import net.minecraft.network.chat.CommonComponents;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.Identifier;
+import org.jspecify.annotations.NonNull;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public class RadialMenuEditScreen extends Screen implements ScreenProcessorProvider {
+	private static final int COLUMN_GAP = 8;
+	private static final int COLUMN_PADDING = 4;
+	private static final int COLUMN_HEADER_HEIGHT = 24;
+	private static final int SECTION_HEIGHT = 19;
+	private static final int ACTION_HEIGHT = 26;
+	private static final int FOOTER_BUTTON_WIDTH = 200;
+	private static final int ROW_GAP = 2;
+
+	private final Screen parent;
+	private final ControllerEntity controller;
+	private final RadialMenuEditModel model;
+	private final List candidates;
+	private final Map candidatesById;
+	private final Processor processor = new Processor(this);
+
+	private HeaderAndFooterLayout layout;
+	private ContainerEventHandler availableContainer;
+	private ContainerEventHandler equippedContainer;
+	private final Map availableEntries = new LinkedHashMap<>();
+	private final Map equippedEntries = new LinkedHashMap<>();
+	private Identifier focusAfterRebuild;
+	private int equippedInsertionIndex;
+
+	public RadialMenuEditScreen(Screen parent, ControllerEntity 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)
+				.toList();
+		this.candidatesById = candidates.stream().collect(Collectors.toMap(
+				InputBinding::id,
+				Function.identity(),
+				(first, ignored) -> first,
+				LinkedHashMap::new
+		));
+		this.equippedInsertionIndex = model.actions().size();
+	}
+
+	@Override
+	protected void init() {
+		availableEntries.clear();
+		equippedEntries.clear();
+		availableContainer = null;
+		equippedContainer = null;
+
+		layout = new HeaderAndFooterLayout(this);
+		layout.addTitleHeader(title, font);
+
+		int columnWidth = Math.max(100, (width - COLUMN_GAP - 16) / 2);
+		GridLayout columns = new GridLayout().columnSpacing(COLUMN_GAP);
+		GridLayout.RowHelper row = columns.createRowHelper(2);
+		row.addChild(createAvailableColumn(columnWidth));
+		row.addChild(createEquippedColumn(columnWidth));
+		columns.arrangeElements();
+		layout.addToContents(columns, LayoutSettings::alignHorizontallyCenter);
+
+		LinearLayout footer = LinearLayout.horizontal();
+		Button done = footer.addChild(Button.builder(CommonComponents.GUI_DONE, _ -> commitAndClose())
+				.width(FOOTER_BUTTON_WIDTH)
+				.build());
+		ButtonGuideApi.addGuideToButton(done, ControlifyBindings.GUI_BACK, ButtonGuidePredicate.always());
+		layout.addToFooter(footer);
+
+		layout.visitWidgets(this::addRenderableWidget);
+		repositionElements();
+	}
+
+	private LinearLayout createAvailableColumn(int width) {
+		LinearLayout column = LinearLayout.vertical().spacing(4);
+		long availableCount = candidates.stream().filter(binding -> !model.actions().contains(binding.id())).count();
+		column.addChild(new ColumnHeader(
+				width,
+				Component.translatable("controlify.radial_menu.editor.available"),
+				availableCount
+		));
+
+		int entryWidth = width - COLUMN_PADDING * 2;
+		LinearLayout content = LinearLayout.vertical().spacing(ROW_GAP);
+		if (model.carriedIsEquipped()) {
+			content.addChild(new TransferTarget(
+					entryWidth,
+					Component.translatable("controlify.radial_menu.editor.remove"),
+					false,
+					this::finishUnequip
+			));
+		}
+
+		Map> grouped = candidates.stream()
+				.filter(binding -> !model.actions().contains(binding.id()))
+				.collect(Collectors.groupingBy(InputBinding::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);
+				content.addChild(entry);
+			}
+		});
+		if (grouped.isEmpty()) {
+			content.addChild(new EmptyState(
+					entryWidth,
+					Component.translatable("controlify.radial_menu.editor.none_available")
+			));
+		}
+
+		ScrollableLayout scrollable = createScrollable(padded(content), width);
+		scrollable.visitWidgets(widget -> {
+			if (widget instanceof ContainerEventHandler container) availableContainer = container;
+		});
+		column.addChild(scrollable);
+		return column;
+	}
+
+	private LinearLayout createEquippedColumn(int width) {
+		LinearLayout column = LinearLayout.vertical().spacing(4);
+		column.addChild(new ColumnHeader(
+				width,
+				Component.translatable("controlify.radial_menu.editor.equipped"),
+				model.actions().size()
+		));
+
+		int entryWidth = width - COLUMN_PADDING * 2;
+		LinearLayout content = LinearLayout.vertical().spacing(ROW_GAP);
+
+		List actions = model.actions();
+		for (int index = 0; index < actions.size(); index++) {
+			if (index % 8 == 0) {
+				content.addChild(new SectionDivider(
+						entryWidth,
+						Component.translatable("controlify.radial_menu.editor.page", index / 8 + 1),
+						true
+				));
+			}
+
+			Identifier id = actions.get(index);
+			InputBinding binding = candidatesById.get(id);
+			Component name = binding == null
+					? Component.translatable("controlify.radial_menu.editor.missing", id.toString()).withStyle(ChatFormatting.RED)
+					: binding.name();
+			ActionEntry entry = new ActionEntry(id, name, true, index, entryWidth);
+			equippedEntries.put(id, entry);
+			content.addChild(entry);
+		}
+
+		if (model.isCarrying()) {
+			content.addChild(new TransferTarget(
+					entryWidth,
+					Component.translatable(
+							model.carriedIsEquipped()
+									? "controlify.radial_menu.editor.move_to_end"
+									: "controlify.radial_menu.editor.add_to_end"
+					),
+					true,
+					this::finishAtEnd
+			));
+		} else if (actions.isEmpty()) {
+			content.addChild(new EmptyState(entryWidth, Component.translatable("controlify.radial_menu.editor.none")));
+		}
+
+		ScrollableLayout scrollable = createScrollable(padded(content), width);
+		scrollable.visitWidgets(widget -> {
+			if (widget instanceof ContainerEventHandler container) equippedContainer = container;
+		});
+		column.addChild(scrollable);
+		return column;
+	}
+
+	private GridLayout padded(LinearLayout content) {
+		GridLayout padded = new GridLayout();
+		padded.addChild(content, 0, 0, settings -> settings.paddingHorizontal(COLUMN_PADDING).paddingVertical(4));
+		padded.arrangeElements();
+		return padded;
+	}
+
+	private ScrollableLayout createScrollable(Layout content, int width) {
+		int height = layout.getContentHeight() - COLUMN_HEADER_HEIGHT - 4;
+		//? if >=26.2 {
+		ScrollableLayout scrollable = new ScrollableLayout(
+				minecraft,
+				content,
+				height,
+				ScrollableLayout.ReserveStrategy.RIGHT
+		);
+		scrollable.setScrollbarSpacing(0);
+		//?} else {
+		/*ScrollableLayout scrollable = new ScrollableLayout(minecraft, content, height);
+		*///?}
+		scrollable.setMinWidth(width);
+		scrollable.setMaxHeight(height);
+		scrollable.setMinHeight(height);
+		return scrollable;
+	}
+
+	private void activate(Identifier id, boolean equipped) {
+		if (!model.isCarrying()) {
+			model.pickUp(id);
+			if (equipped) equippedInsertionIndex = model.actions().indexOf(id);
+			focusAfterRebuild = id;
+			rebuildWidgets();
+		} else if (Objects.equals(id, model.carried())) {
+			model.drop();
+			focusAfterRebuild = id;
+			rebuildWidgets();
+		} else if (equipped) {
+			int destination = model.actions().indexOf(id);
+			if (model.carriedIsEquipped()) {
+				model.moveCarriedTo(destination);
+			} else {
+				model.equipCarried(destination);
+			}
+			focusAfterRebuild = model.carried();
+			model.drop();
+			rebuildWidgets();
+		} else if (model.carriedIsEquipped()) {
+			model.unequipCarried();
+			focusAfterRebuild = model.carried();
+			model.drop();
+			rebuildWidgets();
+		}
+	}
+
+	private boolean moveCarried(ScreenDirection direction) {
+		Identifier carried = model.carried();
+		if (carried == null) return false;
+
+		boolean changed = switch (direction) {
+			case LEFT -> {
+				if (model.carriedIsEquipped()) {
+					equippedInsertionIndex = model.unequipCarried();
+					yield true;
+				}
+				yield false;
+			}
+			case RIGHT -> model.equipCarried(equippedInsertionIndex);
+			case UP -> model.moveCarried(-1);
+			case DOWN -> model.moveCarried(1);
+		};
+
+		if (changed) {
+			if (model.carriedIsEquipped()) equippedInsertionIndex = model.actions().indexOf(carried);
+			focusAfterRebuild = carried;
+			rebuildWidgets();
+		}
+		return changed;
+	}
+
+	private void dropCarried() {
+		Identifier carried = model.carried();
+		model.drop();
+		focusAfterRebuild = carried;
+		rebuildWidgets();
+	}
+
+	private void finishUnequip() {
+		Identifier carried = model.carried();
+		model.unequipCarried();
+		model.drop();
+		focusAfterRebuild = carried;
+		rebuildWidgets();
+	}
+
+	private void finishAtEnd() {
+		Identifier carried = model.carried();
+		if (model.carriedIsEquipped()) {
+			model.moveCarriedTo(model.actions().size() - 1);
+		} else {
+			model.equipCarried(model.actions().size());
+		}
+		model.drop();
+		focusAfterRebuild = carried;
+		rebuildWidgets();
+	}
+
+	@Override
+	protected void setInitialFocus() {
+		ActionEntry target = focusAfterRebuild == null ? null : equippedEntries.get(focusAfterRebuild);
+		ContainerEventHandler container = equippedContainer;
+		if (target == null && focusAfterRebuild != null) {
+			target = availableEntries.get(focusAfterRebuild);
+			container = availableContainer;
+		}
+		focusAfterRebuild = null;
+
+		if (target != null && container != null) {
+			setFocused(container);
+			container.setFocused(target);
+		} else {
+			super.setInitialFocus();
+		}
+	}
+
+	@Override
+	protected void repositionElements() {
+		layout.arrangeElements();
+	}
+
+	@Override
+	public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
+		super.extractRenderState(graphics, mouseX, mouseY, partialTick);
+
+		Identifier headerTexture = minecraft.level == null ? Screen.HEADER_SEPARATOR : Screen.INWORLD_HEADER_SEPARATOR;
+		graphics.blit(
+				RenderPipelines.GUI_TEXTURED,
+				headerTexture,
+				0, layout.getHeaderHeight(),
+				0.0F, 0.0F,
+				width, 2,
+				32, 2
+		);
+		Identifier footerTexture = minecraft.level == null ? Screen.FOOTER_SEPARATOR : Screen.INWORLD_FOOTER_SEPARATOR;
+		graphics.blit(
+				RenderPipelines.GUI_TEXTURED,
+				footerTexture,
+				0, height - layout.getFooterHeight(),
+				0.0F, 0.0F,
+				width, 2,
+				32, 2
+		);
+	}
+
+	@Override
+	public boolean keyPressed(@NonNull KeyEvent event) {
+		if (model.isCarrying()) {
+			if (event.key() == GLFW.GLFW_KEY_ENTER || event.key() == GLFW.GLFW_KEY_SPACE) {
+				dropCarried();
+				return true;
+			}
+			if (event.key() == GLFW.GLFW_KEY_LEFT) {
+				moveCarried(ScreenDirection.LEFT);
+				return true;
+			}
+			if (event.key() == GLFW.GLFW_KEY_RIGHT) {
+				moveCarried(ScreenDirection.RIGHT);
+				return true;
+			}
+			if (event.key() == GLFW.GLFW_KEY_UP) {
+				moveCarried(ScreenDirection.UP);
+				return true;
+			}
+			if (event.key() == GLFW.GLFW_KEY_DOWN) {
+				moveCarried(ScreenDirection.DOWN);
+				return true;
+			}
+		}
+		return super.keyPressed(event);
+	}
+
+	@Override
+	public void onClose() {
+		commitAndClose();
+	}
+
+	private void commitAndClose() {
+		List configured = controller.settings().input.radialMenu.radialActions;
+		if (!configured.equals(model.actions())) {
+			configured.clear();
+			configured.addAll(model.actions());
+			Controlify.instance().config().markDirty();
+			Controlify.instance().config().saveIfDirty();
+		}
+		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;
+
+		int ellipsisWidth = font.width("...");
+		return Component.literal(font.plainSubstrByWidth(value, Math.max(0, maxWidth - ellipsisWidth)) + "...");
+	}
+
+	private class ColumnHeader extends AbstractWidget {
+		private final long count;
+
+		private ColumnHeader(int width, Component message, long count) {
+			super(0, 0, width, COLUMN_HEADER_HEIGHT, message);
+			this.count = count;
+			this.active = false;
+		}
+
+		@Override
+		protected void extractWidgetRenderState(
+				@NonNull GuiGraphicsExtractor graphics,
+				int mouseX,
+				int mouseY,
+				float partialTick
+		) {
+			String countLabel = Long.toString(count);
+			int countWidth = font.width(countLabel);
+			int textY = getY() + 3;
+			Component heading = trim(
+					getMessage().copy().withStyle(ChatFormatting.BOLD),
+					getWidth() - countWidth - 24
+			);
+			graphics.text(font, heading, getX() + 2, textY, 0xFFFFFFFF);
+			graphics.text(font, countLabel, getRight() - countWidth - 2, textY, 0xFF909090);
+			graphics.fill(getX(), getBottom() - 3, getRight(), getBottom() - 2, 0x88555555);
+			graphics.fill(getX(), getBottom() - 3, getX() + Math.min(40, getWidth()), getBottom() - 2, 0xFFFFAA00);
+		}
+
+		@Override
+		protected void updateWidgetNarration(@NonNull NarrationElementOutput output) {
+		}
+	}
+
+	private class SectionDivider extends AbstractWidget {
+		private final boolean equipped;
+
+		private SectionDivider(int width, Component message, boolean equipped) {
+			super(0, 0, width, SECTION_HEIGHT, message);
+			this.equipped = equipped;
+		}
+
+		@Override
+		protected void extractWidgetRenderState(
+				@NonNull GuiGraphicsExtractor graphics,
+				int mouseX,
+				int mouseY,
+				float partialTick
+		) {
+			Component label = trim(getMessage(), getWidth() - 20);
+			int textY = getY() + (getHeight() - font.lineHeight) / 2;
+			int lineY = getY() + getHeight() / 2;
+			int textWidth = font.width(label);
+			boolean columnFocused = getFocused() == (equipped ? equippedContainer : availableContainer);
+			boolean focused = isFocused() && columnFocused;
+			if (focused) {
+				graphics.fill(getX(), getY() + 1, getRight(), getBottom() - 1, 0x88303030);
+				graphics.fill(getX() + 1, getY() + 2, getX() + 3, getBottom() - 2, 0xFFFFCC44);
+			}
+			graphics.text(font, label, getX() + 5, textY, focused ? 0xFFFFFFFF : 0xFFFFAA00);
+			graphics.fill(
+					getX() + textWidth + 12,
+					lineY,
+					getRight() - 5,
+					lineY + 1,
+					focused ? 0xFFAAAAAA : 0x88555555
+			);
+		}
+
+		@Override
+		protected void updateWidgetNarration(@NonNull NarrationElementOutput output) {
+			output.add(NarratedElementType.TITLE, getMessage());
+		}
+	}
+
+	private class EmptyState extends AbstractWidget {
+		private EmptyState(int width, Component message) {
+			super(0, 0, width, ACTION_HEIGHT * 2, message);
+			this.active = false;
+		}
+
+		@Override
+		protected void extractWidgetRenderState(
+				@NonNull GuiGraphicsExtractor graphics,
+				int mouseX,
+				int mouseY,
+				float partialTick
+		) {
+			graphics.centeredText(
+					font,
+					trim(getMessage(), getWidth() - 16),
+					getX() + getWidth() / 2,
+					getY() + (getHeight() - font.lineHeight) / 2,
+					0xFF777777
+			);
+		}
+
+		@Override
+		protected void updateWidgetNarration(@NonNull NarrationElementOutput output) {
+		}
+	}
+
+	private class ActionEntry extends AbstractButton {
+		private final Identifier id;
+		private final boolean equipped;
+		private final int position;
+		private final RadialIcon icon;
+
+		private ActionEntry(Identifier id, Component name, boolean equipped, int position, int width) {
+			super(0, 0, width, ACTION_HEIGHT, name);
+			this.id = id;
+			this.equipped = equipped;
+			this.position = position;
+
+			InputBinding binding = candidatesById.get(id);
+			this.icon = binding == null
+					? RadialIcon.EMPTY
+					: RadialIconManager.INSTANCE.getIcon(binding);
+		}
+
+		@Override
+		public void onPress(InputWithModifiers input) {
+			activate(id, equipped);
+		}
+
+		@Override
+		protected void extractContents(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
+			boolean columnFocused = getFocused() == (equipped ? equippedContainer : availableContainer);
+			boolean focused = isFocused() && columnFocused;
+			boolean carried = Objects.equals(id, model.carried());
+			int background = focused
+					? 0xDD303030
+					: isHovered() ? 0xCC252525 : 0xAA101010;
+			int border = carried
+					? 0xFFFFCC44
+					: focused ? 0xFFFFFFFF : isHovered() ? 0xFFAAAAAA : 0xFF555555;
+
+			graphics.fill(getX() + 1, getY() + 1, getRight() - 1, getBottom() - 1, background);
+			graphics.outline(getX(), getY(), getWidth(), getHeight(), border);
+			if (carried) {
+				graphics.fill(getX() + 2, getY() + 2, getX() + 5, getBottom() - 2, 0xFFFFCC44);
+			} else if (equipped) {
+				graphics.fill(getX() + 2, getY() + 2, getX() + 4, getBottom() - 2, 0xFF5599DD);
+			}
+
+			RadialIconExtractor.extract(graphics, icon, getX() + 7, getY() + 5);
+			int textX = getX() + 28;
+			int rightPadding = equipped ? 27 : 8;
+			int textY = getY() + (getHeight() - font.lineHeight) / 2;
+			int textColor = carried ? 0xFFFFDD66 : 0xFFFFFFFF;
+			graphics.text(font, trim(getMessage(), getRight() - textX - rightPadding), textX, textY, textColor);
+
+			if (equipped) {
+				String slot = Integer.toString(position % 8 + 1);
+				graphics.text(font, slot, getRight() - font.width(slot) - 8, textY, 0xFF888888);
+			}
+		}
+
+		@Override
+		protected void updateWidgetNarration(@NonNull NarrationElementOutput output) {
+			defaultButtonNarrationText(output);
+		}
+	}
+
+	private class TransferTarget extends AbstractButton {
+		private final boolean equipped;
+		private final Runnable action;
+
+		private TransferTarget(int width, Component message, boolean equipped, Runnable action) {
+			super(0, 0, width, ACTION_HEIGHT, message);
+			this.equipped = equipped;
+			this.action = action;
+		}
+
+		@Override
+		public void onPress(InputWithModifiers input) {
+			action.run();
+		}
+
+		@Override
+		protected void extractContents(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) {
+			boolean columnFocused = getFocused() == (equipped ? equippedContainer : availableContainer);
+			boolean focused = isFocused() && columnFocused;
+			int background = focused
+					? 0xBB303030
+					: isHovered() ? 0xAA252525 : 0x66101010;
+			int border = focused ? 0xFFFFFFFF : isHovered() ? 0xFFAAAAAA : 0xFF777777;
+			graphics.fill(getX() + 1, getY() + 1, getRight() - 1, getBottom() - 1, background);
+			graphics.outline(getX(), getY(), getWidth(), getHeight(), border);
+			graphics.centeredText(
+					font,
+					trim(getMessage(), getWidth() - 16),
+					getX() + getWidth() / 2,
+					getY() + (getHeight() - font.lineHeight) / 2,
+					0xFFFFCC44
+			);
+		}
+
+		@Override
+		protected void updateWidgetNarration(@NonNull NarrationElementOutput output) {
+			defaultButtonNarrationText(output);
+		}
+	}
+
+	public static class Processor extends ScreenProcessor {
+		public Processor(RadialMenuEditScreen screen) {
+			super(screen);
+		}
+
+		@Override
+		protected void handleComponentNavigation(ControllerEntity controller) {
+			if (!screen.model.isCarrying()) {
+				super.handleComponentNavigation(controller);
+				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);
+			}
+
+			if (moved) {
+				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();
+					return;
+				}
+			}
+			super.handleButtons(controller);
+		}
+
+		@Override
+		public VirtualMouseBehaviour virtualMouseBehaviour() {
+			return VirtualMouseBehaviour.DISABLED;
+		}
+	}
+}
diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuPages.java b/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuPages.java
new file mode 100644
index 000000000..6ae415e11
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuPages.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.gui.screen;
+
+import java.util.ArrayList;
+import java.util.List;
+
+final class RadialMenuPages {
+	static final int PAGE_SIZE = 8;
+
+	static  List> partition(List items) {
+		if (items.isEmpty()) return List.of(List.of());
+
+		List> pages = new ArrayList<>((items.size() + PAGE_SIZE - 1) / PAGE_SIZE);
+		for (int start = 0; start < items.size(); start += PAGE_SIZE) {
+			pages.add(List.copyOf(items.subList(start, Math.min(start + PAGE_SIZE, items.size()))));
+		}
+		return List.copyOf(pages);
+	}
+
+	private RadialMenuPages() {
+	}
+}
diff --git a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuScreen.java b/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuScreen.java
index 8477d8573..838ce9814 100644
--- a/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuScreen.java
+++ b/src/main/java/dev/isxander/controlify/gui/screen/RadialMenuScreen.java
@@ -6,13 +6,13 @@
  */
 package dev.isxander.controlify.gui.screen;
 
-import dev.isxander.controlify.Controlify;
+import dev.isxander.controlify.api.bind.InputBinding;
 import dev.isxander.controlify.api.bind.RadialIcon;
 import dev.isxander.controlify.bindings.ControlifyBindings;
-import dev.isxander.controlify.api.bind.InputBinding;
+import dev.isxander.controlify.bindings.RadialIconExtractor;
 import dev.isxander.controlify.controller.ControllerEntity;
 import dev.isxander.controlify.controller.haptic.HapticEffects;
-import dev.isxander.controlify.screenop.ComponentProcessor;
+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;
@@ -22,17 +22,14 @@
 import dev.isxander.controlify.utils.animation.api.Animation;
 import dev.isxander.controlify.utils.animation.api.EasingFunction;
 import dev.isxander.controlify.virtualmouse.VirtualMouseBehaviour;
-import net.minecraft.client.gui.ComponentPath;
 import net.minecraft.client.gui.GuiGraphicsExtractor;
 import net.minecraft.client.gui.TextAlignment;
 import net.minecraft.client.gui.components.MultiLineLabel;
 import net.minecraft.client.gui.components.Renderable;
-import net.minecraft.client.gui.components.events.ContainerEventHandler;
 import net.minecraft.client.gui.components.events.GuiEventListener;
 import net.minecraft.client.gui.narration.NarratableEntry;
 import net.minecraft.client.gui.narration.NarratedElementType;
 import net.minecraft.client.gui.narration.NarrationElementOutput;
-import net.minecraft.client.gui.navigation.FocusNavigationEvent;
 import net.minecraft.client.gui.navigation.ScreenRectangle;
 import net.minecraft.client.gui.screens.Screen;
 import net.minecraft.client.renderer.RenderPipelines;
@@ -44,39 +41,59 @@
 import org.jetbrains.annotations.Nullable;
 import org.jspecify.annotations.NonNull;
 
-import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 
 public class RadialMenuScreen extends Screen implements ScreenControllerEventListener, ScreenProcessorProvider {
-	public static final Identifier EMPTY_ACTION = CUtil.rl("empty_action");
-
 	private final ControllerEntity controller;
-	private final @Nullable EditMode editMode;
-	private final Screen parent;
-	private final Component text;
+	private final @Nullable Screen parent;
+	private final Component footerText;
+	private final List userPages;
+	private final List bonusPages;
+	private final @Nullable InputBinding openBind;
 
-	private final RadialItem[] items;
-	private final RadialButton[] buttons;
+	private RadialButton[] buttons = new RadialButton[0];
 	private float radialRadius;
-
-	private final InputBinding openBind;
-
+	private int pageIndex;
 	private int selectedButton = -1;
 	private int idleTicks;
 	private final int idleTicksTimeout;
-	private boolean isEditing;
-
-	private ActionSelectList actionSelectList;
 
 	private final Processor processor = new Processor(this);
 
-	public RadialMenuScreen(ControllerEntity controller, InputBinding openBind, RadialItem[] items, Component text, @Nullable EditMode editMode, Screen parent) {
+	/**
+	 * 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);
+	}
+
+	/**
+	 * 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(
+			ControllerEntity controller,
+			@Nullable InputBinding openBind,
+			List userPages,
+			List bonusPages,
+			Component text,
+			@Nullable Screen parent
+	) {
 		super(text);
-		this.text = text;
+		this.footerText = text;
 		this.controller = controller;
-		this.items = items;
-		this.buttons = new RadialButton[items.length];
-		this.editMode = editMode;
+		this.userPages = userPages.isEmpty()
+				? Collections.singletonList(new RadialItem[0])
+				: userPages.stream().map(RadialItem[]::clone).toList();
+		this.bonusPages = bonusPages.stream()
+				.map(page -> new RadialPage(page.name(), page.items().clone()))
+				.toList();
+		this.pageIndex = this.bonusPages.size();
 		this.parent = parent;
 		this.idleTicksTimeout = controller.input().orElseThrow().settings().radialMenu.radialButtonFocusTimeoutTicks;
 		this.openBind = openBind;
@@ -84,108 +101,99 @@ public RadialMenuScreen(ControllerEntity controller, InputBinding openBind, Radi
 
 	@Override
 	protected void init() {
+		buildCurrentPage();
+	}
+
+	private void buildCurrentPage() {
+		Arrays.stream(buttons).forEach(this::removeWidget);
+		selectedButton = -1;
+		idleTicks = 0;
+		setFocused(null);
+
+		RadialItem[] items = getCurrentPageItems();
+		buttons = new RadialButton[items.length];
+		if (items.length == 0) {
+			return;
+		}
+
 		int centerX = this.width / 2;
 		int centerY = this.height / 2;
-
-		// get diameter of enclosing circle of a radial button
-		// w = 16, h = 16
-		// r = sqrt(w^2 + h^2) / 2
-		float buttonRadius = (float) Math.sqrt(32 * 32 + 32 * 32) + 8;
-		// add the amount of radii together to create the circumference of the circle they all fit around
-		float circumference = buttonRadius * items.length;
-		// c = 2 * pi * r
+		float buttonDiameter = (float)Math.sqrt(32 * 32 + 32 * 32) + 8;
+		float circumference = buttonDiameter * items.length;
 		radialRadius = Math.max(circumference / Mth.TWO_PI, 43);
 
-		Animation animation = Animation.of(5)
-				.easing(EasingFunction.EASE_OUT_QUAD);
+		Animation animation = Animation.of(5).easing(EasingFunction.EASE_OUT_QUAD);
 		for (int i = 0; i < items.length; i++) {
 			float angle = Mth.TWO_PI * i / items.length - (90 * Mth.DEG_TO_RAD);
 			float x = centerX + Mth.cos(angle) * radialRadius;
 			float y = centerY + Mth.sin(angle) * radialRadius;
 
 			RadialButton button = buttons[i] = new RadialButton(items[i], centerX - 16, centerY - 16);
-
 			animation
 					.consumerF(button::setX, centerX - 16, x - 16)
 					.consumerF(button::setY, centerY - 16, y - 16);
-
 			addRenderableWidget(button);
 		}
 		animation.play();
+	}
 
-		if (editMode != null) {
-//            var exitGuide = addRenderableWidget(new PositionedComponent<>(
-//                    new GuideActionRenderer(
-//                            new GuideAction(
-//                                    ControlifyBindings.GUI_BACK.on(controller),
-//                                    obj -> Optional.of(CommonComponents.GUI_DONE)
-//                            ),
-//                            false,
-//                            true
-//                    ),
-//                    AnchorPoint.BOTTOM_CENTER,
-//                    0, -10,
-//                    AnchorPoint.BOTTOM_CENTER
-//            ));
-//
-//            exitGuide.getComponent().updateName(null);
-//            exitGuide.updatePosition(width, height);
-		}
+	private void changePage(int direction) {
+		if (pageCount() <= 1) return;
+
+		pageIndex = Math.floorMod(pageIndex + direction, pageCount());
+		buildCurrentPage();
+		minecraft.getSoundManager().play(SimpleSoundInstance.forUI(ControlifyClientSounds.SCREEN_FOCUS_CHANGE.get(), 1f));
+		controller.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE));
 	}
 
 	@Override
 	public void onControllerInput(ControllerEntity controller) {
 		if (this.controller != controller) return;
 
-		if (editMode == null && !openBind.digitalNow()) {
-			if (selectedButton != -1 && buttons[selectedButton].invoke()) {
+		if (openBind == null) {
+			if (ControlifyBindings.GUI_BACK.on(controller).justPressed()) onClose();
+			return;
+		}
+
+		if (!openBind.digitalNow()) {
+			if (selectedButton >= 0 && selectedButton < buttons.length && buttons[selectedButton].invoke()) {
 				playClickSound();
 			}
-
 			onClose();
+			return;
 		}
 
-		if (editMode != null && ControlifyBindings.GUI_BACK.on(controller).justPressed()) {
-			playClickSound();
-			onClose();
-		}
+		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;
+
+		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 each = 360f / buttons.length;
+			int newSelected = Mth.floor((angle + each / 2f) / each) % buttons.length;
+
+			if (newSelected != selectedButton) {
+				selectedButton = newSelected;
+				minecraft.getSoundManager().play(SimpleSoundInstance.forUI(ControlifyClientSounds.SCREEN_FOCUS_CHANGE.get(), 1f));
+				controller.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE));
+			}
 
-		if (!isEditing) {
-			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;
-
-			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 each = 360f / buttons.length;
-
-				int newSelected = Mth.floor((angle + each / 2f) / each) % buttons.length;
-				if (newSelected != selectedButton) {
-					selectedButton = newSelected;
-					minecraft.getSoundManager().play(SimpleSoundInstance.forUI(ControlifyClientSounds.SCREEN_FOCUS_CHANGE.get(), 1f));
-					controller.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE));
-				}
-
-				for (int i = 0; i < buttons.length; i++) {
-					boolean selected = i == selectedButton;
-					buttons[i].setFocused(selected);
-					if (selected) {
-						this.setFocused(buttons[i]);
-					}
-				}
-
-				idleTicks = 0;
-			} else if (editMode == null) {
-				idleTicks++;
-				if (idleTicks >= idleTicksTimeout && selectedButton != -1) {
-					selectedButton = -1;
-					for (RadialButton button : buttons) {
-						button.setFocused(false);
-					}
-					controller.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE));
-				}
+			for (int i = 0; i < buttons.length; i++) {
+				boolean selected = i == selectedButton;
+				buttons[i].setFocused(selected);
+				if (selected) setFocused(buttons[i]);
+			}
+			idleTicks = 0;
+		} else {
+			idleTicks++;
+			if (idleTicks >= idleTicksTimeout && selectedButton != -1) {
+				selectedButton = -1;
+				for (RadialButton button : buttons) button.setFocused(false);
+				controller.hdHaptics().ifPresent(haptics -> haptics.playHaptic(HapticEffects.NAVIGATE));
 			}
 		}
 	}
@@ -194,49 +202,74 @@ public void onControllerInput(ControllerEntity controller) {
 	public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) {
 		super.extractRenderState(graphics, mouseX, mouseY, delta);
 
-		if (editMode == null) {
-			graphics.centeredText(
-					font,
-					text,
-					width / 2,
-					height - 39,
-					-1
-			);
+		if (buttons.length == 0) {
+			graphics.centeredText(font, Component.translatable("controlify.radial_menu.empty"), width / 2, height / 2, -1);
 		}
-	}
+		graphics.centeredText(font, footerText, width / 2, height - 39, -1);
 
-	@Override
-	public void extractBackground(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
-		if (editMode != null) {
-			super.extractBackground(graphics, mouseX, mouseY, a);
+		if (pageCount() > 1) {
+			Component page = getPageTitle();
+			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();
+			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);
 		}
 	}
 
-	private void playClickSound() {
-		minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1f));
+	private RadialItem[] getCurrentPageItems() {
+		if (pageIndex < bonusPages.size()) return bonusPages.get(pageIndex).items();
+
+		return userPages.get(pageIndex - bonusPages.size());
 	}
 
-	private void finishEditing() {
-		isEditing = false;
-		removeWidget(actionSelectList);
-		this.setFocused(null);
-		actionSelectList = null;
+	private Component getPageTitle() {
+		if (pageIndex < bonusPages.size()) return bonusPages.get(pageIndex).name();
+
+		return Component.translatable(
+				"controlify.radial_menu.page",
+				pageIndex + 1 - bonusPages.size(),
+				userPages.size()
+		);
+	}
+
+	private int pageCount() {
+		return bonusPages.size() + userPages.size();
+	}
+
+	private void renderPageDots(GuiGraphicsExtractor graphics) {
+		int visibleCount = Math.min(pageCount(), 15);
+		int firstPage = Math.clamp(pageIndex - visibleCount / 2, 0, pageCount() - visibleCount);
+		int totalWidth = visibleCount * 5 - 2;
+		int startX = (width - totalWidth) / 2;
+
+		for (int index = 0; index < visibleCount; index++) {
+			int representedPage = firstPage + index;
+			int color = representedPage == pageIndex ? 0xffffffff : 0x80ffffff;
+			graphics.fill(startX + index * 5, height - 13, startX + index * 5 + 3, height - 10, color);
+		}
 	}
 
 	@Override
 	public void onClose() {
-		Controlify.instance().config().saveIfDirty();
 		MinecraftUtil.setScreen(parent);
 	}
 
 	@Override
 	public boolean isPauseScreen() {
-		return editMode != null;
+		return false;
 	}
 
 	@Override
 	public ScreenProcessor screenProcessor() {
-		return this.processor;
+		return processor;
+	}
+
+	private void playClickSound() {
+		minecraft.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1f));
 	}
 
 	public interface RadialItem {
@@ -247,97 +280,55 @@ public interface RadialItem {
 		boolean playAction();
 	}
 
-	public interface EditMode {
-		void setRadialItem(int index, RadialItem item);
-
-		List getEditCandidates();
-	}
-
-	public class RadialButton implements Renderable, GuiEventListener, NarratableEntry, ComponentProcessor {
+	public class RadialButton implements Renderable, GuiEventListener, NarratableEntry {
 		public static final Identifier TEXTURE = CUtil.rl("textures/gui/radial-buttons.png");
 
 		private int x, y;
 		private float translateX, translateY;
 		private boolean focused;
-		private RadialItem item;
-		private MultiLineLabel name;
+		private final RadialItem item;
+		private final MultiLineLabel name;
 
 		private RadialButton(RadialItem item, float x, float y) {
-			this.setX(x);
-			this.setY(y);
-
-			this.setAction(item);
+			setX(x);
+			setY(y);
+			this.item = item;
+			this.name = MultiLineLabel.create(font, item.name(), (int)(radialRadius * 2 - 32));
 		}
 
 		@Override
-		public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
+		public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) {
 			var pose = graphics.pose().pushMatrix();
 			pose.translate(x + translateX, y + translateY);
-
 			pose.pushMatrix();
 			pose.scale(2, 2);
-
-			int textureX = focused ? 16 : 0;
-			graphics.blit(
-					RenderPipelines.GUI_TEXTURED,
-					TEXTURE,
-					0, 0,
-					textureX, 0,
-					16, 16,
-					32, 16
-			);
-
+			graphics.blit(RenderPipelines.GUI_TEXTURED, TEXTURE, 0, 0, focused ? 16 : 0, 0, 16, 16, 32, 16);
 			pose.popMatrix();
 
-			if (editMode == null || !focused) {
-				pose.pushMatrix();
-				pose.translate(4, 4);
-				pose.scale(1.5f, 1.5f);
-				this.item.icon().draw(graphics, 0, 0, a);
-				pose.popMatrix();
-			} else {
-				Component bind = ControlifyBindings.GUI_PRESS.on(controller).inputGlyph();
-				graphics.text(font, bind, 16 - font.width(bind) / 2, 16 - font.lineHeight / 2, -1);
-			}
-
+			pose.pushMatrix();
+			pose.translate(4, 4);
+			pose.scale(1.5f, 1.5f);
+			RadialIconExtractor.extract(graphics, item.icon(), 0, 0);
+			pose.popMatrix();
 			pose.popMatrix();
 
 			if (focused) {
-				int anchorX = width / 2;
 				int topY = height / 2 - font.lineHeight / 2 - ((name.getLineCount() - 1) * font.lineHeight);
-
-				name.visitLines(
-						TextAlignment.CENTER,
-						anchorX, topY, font.lineHeight,
-						graphics.textRenderer()
-				);
+				name.visitLines(TextAlignment.CENTER, width / 2, topY, font.lineHeight, graphics.textRenderer());
 			}
 		}
 
-		public boolean invoke() {
-			return this.item.playAction();
-		}
-
-		public void setAction(RadialItem item) {
-			this.item = item;
-			this.name = MultiLineLabel.create(font, item.name(), (int)(radialRadius * 2 - 32));
-		}
-
-		public int getX() {
-			return x;
+		private boolean invoke() {
+			return item.playAction();
 		}
 
-		public int getY() {
-			return y;
-		}
-
-		public void setX(float x) {
-			this.x = (int) x;
+		private void setX(float x) {
+			this.x = (int)x;
 			this.translateX = x - this.x;
 		}
 
-		public void setY(float y) {
-			this.y = (int) y;
+		private void setY(float y) {
+			this.y = (int)y;
 			this.translateY = y - this.y;
 		}
 
@@ -351,23 +342,9 @@ public void setFocused(boolean focused) {
 			this.focused = focused;
 		}
 
-		@Override
-		public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) {
-			if (editMode != null && controller == RadialMenuScreen.this.controller && ControlifyBindings.GUI_PRESS.on(controller).justPressed()) {
-				RadialButton button = buttons[selectedButton];
-				int x = button.x < width / 2 ? button.x - 110 : button.x + 42;
-				actionSelectList = new ActionSelectList(selectedButton, x, button.y, 250, 80);
-				addRenderableWidget(actionSelectList);
-				RadialMenuScreen.this.setFocused(actionSelectList);
-				isEditing = true;
-				return true;
-			}
-			return false;
-		}
-
 		@Override
 		public @NonNull NarrationPriority narrationPriority() {
-			return isFocused() ? NarrationPriority.FOCUSED : NarrationPriority.NONE;
+			return focused ? NarrationPriority.FOCUSED : NarrationPriority.NONE;
 		}
 
 		@Override
@@ -381,184 +358,18 @@ public void updateNarration(NarrationElementOutput builder) {
 		}
 	}
 
-	public class ActionSelectList implements Renderable, ContainerEventHandler, NarratableEntry, ComponentProcessor {
-		private final int radialIndex;
-
-		private int x, y;
-		private int width, height;
-		private final int itemHeight = 10;
-		private int scrollOffset;
-
-		private boolean focused;
-		private ActionEntry focusedEntry;
-
-		private final List children = new ArrayList<>();
-
-		public ActionSelectList(int index, int x, int y, int width, int height) {
-			this.radialIndex = index;
-			this.x = x;
-			this.y = y;
-			this.width = width;
-			this.height = height;
-
-			for (RadialItem item : editMode.getEditCandidates()) {
-				children.add(new ActionEntry(item));
-			}
-
-			RadialItem item = items[radialIndex];
-			children.stream()
-					.filter(action -> action.item.equals(item))
-					.findAny()
-					.ifPresent(this::setFocused);
-		}
-
-		@Override
-		public void extractRenderState(@NonNull GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
-			graphics.fill(x, y, x + width, y + height, 0x80000000);
-
-			graphics.enableScissor(x, y, x + width, y + height);
-			int y = this.y - scrollOffset;
-			for (ActionEntry child : children) {
-				child.render(graphics, x, y, width, itemHeight, mouseX, mouseY, a);
-				y += itemHeight;
-			}
-			graphics.disableScissor();
-
-			graphics.outline(x - 1, this.y - 1, width + 2, height + 2, 0x80ffffff);
-		}
-
-		@Override
-		public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) {
-			if (controller == RadialMenuScreen.this.controller) {
-				if (ControlifyBindings.GUI_BACK.on(controller).justPressed()) {
-					finishEditing();
-					return true;
-				}
-			}
-
-			return false;
-		}
-
-		@Override
-		public List children() {
-			return children;
-		}
-
-		@Override
-		public boolean isDragging() {
-			return false;
-		}
-
-		@Override
-		public void setDragging(boolean dragging) {
-
-		}
-
-		@Nullable @Override
-		public ActionEntry getFocused() {
-			return focusedEntry;
-		}
-
-		@Override
-		public void setFocused(@Nullable GuiEventListener child) {
-			ActionEntry focus = (ActionEntry) child;
-			this.focusedEntry = focus;
-
-			if (focus != null) {
-				int index = children().indexOf(child);
-				if (index != -1) {
-					int focusY = index * itemHeight - scrollOffset;
-					if (focusY < 0)
-						scrollOffset = Mth.clamp(index * itemHeight, 0, children().size() * itemHeight - height);
-					else if (focusY + itemHeight > height)
-						scrollOffset = Mth.clamp(index * itemHeight + itemHeight - height, 0, children().size() * itemHeight - height);
-				}
-			}
-		}
-
-		@Override
-		public void setFocused(boolean focused) {
-			this.focused = focused;
-		}
-
-		@Override
-		public boolean isFocused() {
-			return focused;
-		}
-
-		@Override
-		public @NonNull NarrationPriority narrationPriority() {
-			return focused ? NarrationPriority.FOCUSED : NarrationPriority.NONE;
+	public static class Processor extends ScreenProcessor {
+		public Processor(RadialMenuScreen screen) {
+			super(screen);
 		}
 
 		@Override
-		public void updateNarration(@NonNull NarrationElementOutput builder) {
-			if (getFocused() != null) {
-				builder.add(NarratedElementType.TITLE, getFocused().item.name());
-			}
-		}
-
-		public class ActionEntry implements GuiEventListener, ComponentProcessor {
-			private int x, y;
-			private boolean focused;
-			private final RadialItem item;
-
-			public ActionEntry(RadialItem item) {
-				this.item = item;
-			}
-
-			public void render(GuiGraphicsExtractor graphics, int x, int y, int width, int itemHeight, int mouseX, int mouseY, float delta) {
-				this.x = x;
-				this.y = y;
-
-				if (focused)
-					graphics.fill(x, y, x + width, y + itemHeight, 0xff000000);
-				graphics.text(RadialMenuScreen.this.font, item.name(), x + 2, y + 1, focused ? -1 : 0xffa6a6a6);
+		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);
 			}
-
-			@Override
-			public void setFocused(boolean focused) {
-				this.focused = focused;
-			}
-
-			@Override
-			public boolean isFocused() {
-				return focused;
-			}
-
-			@Nullable @Override
-			public ComponentPath nextFocusPath(FocusNavigationEvent event) {
-				return !focused ? ComponentPath.leaf(this) : null;
-			}
-
-			@Override
-			public ScreenRectangle getRectangle() {
-				return new ScreenRectangle(x, y, width, itemHeight);
-			}
-
-			@Override
-			public boolean overrideControllerButtons(ScreenProcessor screen, ControllerEntity controller) {
-				if (controller == RadialMenuScreen.this.controller) {
-					if (ControlifyBindings.GUI_PRESS.on(controller).justPressed()) {
-						editMode.setRadialItem(radialIndex, item);
-						Controlify.instance().config().markDirty();
-
-						buttons[radialIndex].setAction(item);
-
-						playClickSound();
-						finishEditing();
-						return true;
-					}
-				}
-
-				return false;
-			}
-		}
-	}
-
-	public static class Processor extends ScreenProcessor {
-		public Processor(RadialMenuScreen screen) {
-			super(screen);
 		}
 
 		@Override
diff --git a/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java b/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java
index 2562d2ddd..eaeab2dc0 100644
--- a/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java
+++ b/src/main/java/dev/isxander/controlify/ingame/InGameInputHandler.java
@@ -19,6 +19,7 @@
 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;
 import dev.isxander.controlify.mixins.feature.steamdeck.ScreenshotAccessor;
 import dev.isxander.controlify.server.ServerPolicies;
@@ -38,6 +39,7 @@
 import net.minecraft.core.Direction;
 import net.minecraft.network.chat.Component;
 import net.minecraft.network.protocol.game.ServerboundPlayerActionPacket;
+import net.minecraft.server.commands.GameModeCommand;
 import net.minecraft.util.Mth;
 import net.minecraft.world.InteractionHand;
 import net.minecraft.world.entity.player.Inventory;
@@ -48,6 +50,8 @@
 import org.joml.Vector2f;
 
 import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
 
 public class InGameInputHandler {
 	private final ControllerEntity controller;
@@ -181,13 +185,34 @@ protected void handleKeybinds() {
 		if (ControlifyBindings.TOGGLE_DEBUG_MENU_PROF.on(controller).justPressed()) {
 			DebugOverlayHelper.toggleProfilerOverlay();
 		}
+		if (ControlifyBindings.DEBUG_RELOAD_CHUNKS.on(controller).justPressed()) {
+			DebugOverlayHelper.reloadChunks();
+		}
+		if (ControlifyBindings.DEBUG_TOGGLE_CHUNK_BORDERS.on(controller).justPressed()) {
+			DebugOverlayHelper.toggleChunkBorders();
+		}
+		if (ControlifyBindings.DEBUG_TOGGLE_ADVANCED_TOOLTIPS.on(controller).justPressed()) {
+			DebugOverlayHelper.toggleAdvancedTooltips();
+		}
+		if (ControlifyBindings.DEBUG_TOGGLE_ENTITY_HITBOXES.on(controller).justPressed()) {
+			DebugOverlayHelper.toggleEntityHitboxes();
+		}
+		if (ControlifyBindings.DEBUG_RELOAD_RESOURCE_PACKS.on(controller).justPressed()) {
+			DebugOverlayHelper.reloadResourcePacks();
+		}
+		if (ControlifyBindings.DEBUG_CLEAR_CHAT.on(controller).justPressed()) {
+			DebugOverlayHelper.clearChat();
+		}
+		if (ControlifyBindings.DEBUG_START_STOP_PROFILING.on(controller).justPressed()) {
+			DebugOverlayHelper.startStopProfiling();
+		}
 		if (ControlifyBindings.DEBUG_RADIAL.on(controller).justPressed()) {
 			MinecraftUtil.setScreen(new RadialMenuScreen(
 					controller,
 					ControlifyBindings.DEBUG_RADIAL.on(controller),
-					RadialItems.createDebug(),
+					RadialItems.createDebug(controller),
 					Component.empty(),
-					null, null
+					null
 			));
 		}
 
@@ -228,22 +253,28 @@ protected void handleKeybinds() {
 		}
 
 		if (ControlifyBindings.RADIAL_MENU.on(controller).justPressed()) {
+			var bonusPages = new ArrayList();
+			if (canSwitchGameMode()) {
+				bonusPages.add(RadialItems.createGameModes());
+			}
+
 			MinecraftUtil.setScreen(new RadialMenuScreen(
 					controller,
 					ControlifyBindings.RADIAL_MENU.on(controller),
-					RadialItems.createBindings(controller),
+					RadialItems.createBindingPages(controller),
+					bonusPages,
 					Component.translatable("controlify.radial_menu.configure_hint"),
-					null, null
+					null
 			));
 		}
 
-		if (ControlifyBindings.GAME_MODE_SWITCHER.on(controller).justPressed()) {
+		if (ControlifyBindings.GAME_MODE_SWITCHER.on(controller).justPressed() && canSwitchGameMode()) {
 			MinecraftUtil.setScreen(new RadialMenuScreen(
 					controller,
 					ControlifyBindings.GAME_MODE_SWITCHER.on(controller),
-					RadialItems.createGameModes(),
+					RadialItems.createGameModes().items(),
 					Component.empty(),
-					null, null)
+					null)
 			);
 		}
 
@@ -253,7 +284,7 @@ protected void handleKeybinds() {
 					ControlifyBindings.HOTBAR_SLOT_SELECT.on(controller),
 					RadialItems.createHotbarItemSelect(),
 					Component.empty(),
-					null, null
+					null
 			));
 		}
 
@@ -262,23 +293,29 @@ protected void handleKeybinds() {
 				MinecraftUtil.setScreen(new RadialMenuScreen(
 						controller,
 						ControlifyBindings.HOTBAR_LOAD_RADIAL.on(controller),
-						RadialItems.createHotbarLoad(),
+						RadialItems.createHotbarLoad().items(),
 						Component.translatable("controlify.radial.hotbar_load_hint"),
-						null, null
+						null
 				));
 			}
 			if (ControlifyBindings.HOTBAR_SAVE_RADIAL.on(controller).justPressed()) {
 				MinecraftUtil.setScreen(new RadialMenuScreen(
 						controller,
 						ControlifyBindings.HOTBAR_SAVE_RADIAL.on(controller),
-						RadialItems.createHotbarSave(),
+						RadialItems.createHotbarSave().items(),
 						Component.translatable("controlify.radial.hotbar_save_hint"),
-						null, null
+						null
 				));
 			}
 		}
 	}
 
+	private boolean canSwitchGameMode() {
+		return minecraft.canSwitchGameMode()
+				&& minecraft.player != null
+				&& GameModeCommand.PERMISSION_CHECK.check(minecraft.player.permissions());
+	}
+
 	protected void handlePlayerLookInput(boolean isController) {
 		LocalPlayer player = this.minecraft.player;
 
diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/bind/GuiGraphicsExtractorAccessor.java b/src/main/java/dev/isxander/controlify/mixins/feature/bind/GuiGraphicsExtractorAccessor.java
new file mode 100644
index 000000000..6a47a1ab6
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/mixins/feature/bind/GuiGraphicsExtractorAccessor.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.mixins.feature.bind;
+
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.renderer.state.gui.GuiRenderState;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@Mixin(GuiGraphicsExtractor.class)
+public interface GuiGraphicsExtractorAccessor {
+	@Accessor("guiRenderState")
+	GuiRenderState controlify$getGuiRenderState();
+}
diff --git a/src/main/java/dev/isxander/controlify/mixins/feature/bind/ItemStackRenderStateAccessor.java b/src/main/java/dev/isxander/controlify/mixins/feature/bind/ItemStackRenderStateAccessor.java
new file mode 100644
index 000000000..d8218974a
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/mixins/feature/bind/ItemStackRenderStateAccessor.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.mixins.feature.bind;
+
+import net.minecraft.client.renderer.item.ItemStackRenderState;
+import net.minecraft.world.item.ItemDisplayContext;
+import org.spongepowered.asm.mixin.Mixin;
+import org.spongepowered.asm.mixin.gen.Accessor;
+
+@Mixin(ItemStackRenderState.class)
+public interface ItemStackRenderStateAccessor {
+	@Accessor("displayContext")
+	void controlify$setDisplayContext(ItemDisplayContext displayContext);
+}
diff --git a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessor.java b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessor.java
index b1e1cf152..64eeb95dd 100644
--- a/src/main/java/dev/isxander/controlify/screenop/ScreenProcessor.java
+++ b/src/main/java/dev/isxander/controlify/screenop/ScreenProcessor.java
@@ -161,6 +161,10 @@ protected void handleComponentNavigation(ControllerEntity controller) {
 		}
 
 		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();
 
diff --git a/src/main/resources/assets/controlify/controllers/default_config.json b/src/main/resources/assets/controlify/controllers/default_config.json
index e085ad660..f049f76d1 100644
--- a/src/main/resources/assets/controlify/controllers/default_config.json
+++ b/src/main/resources/assets/controlify/controllers/default_config.json
@@ -40,7 +40,18 @@
         "controlify:swap_hands",
         "controlify:pick_block",
         "controlify:take_screenshot",
-        "controlify:show_player_list"
+        "controlify:show_player_list",
+        "controlify:toggle_debug_menu",
+        "controlify:toggle_debug_menu_fps",
+        "controlify:toggle_debug_menu_net",
+        "controlify:toggle_debug_menu_prof",
+        "controlify:debug_reload_chunks",
+        "controlify:debug_toggle_chunk_borders",
+        "controlify:debug_toggle_advanced_tooltips",
+        "controlify:debug_toggle_entity_hitboxes",
+        "controlify:debug_reload_resource_packs",
+        "controlify:debug_clear_chat",
+        "controlify:debug_start_stop_profiling"
       ],
       "button_focus_timeout_ticks": 20
     },
diff --git a/src/main/resources/assets/controlify/lang/en_us.json b/src/main/resources/assets/controlify/lang/en_us.json
index 1960a11a8..d78e2bdb0 100644
--- a/src/main/resources/assets/controlify/lang/en_us.json
+++ b/src/main/resources/assets/controlify/lang/en_us.json
@@ -512,6 +512,18 @@
   "controlify.guide.container.take_from_bundle": "Take From Bundle",
 
   "controlify.radial_menu.configure_hint": "Configure actions in the controller settings.",
+  "controlify.radial_menu.empty": "No radial actions configured.",
+  "controlify.radial_menu.page": "Page %s / %s",
+  "controlify.radial_menu.editor.title": "Configure Radial Menu",
+  "controlify.radial_menu.editor.available": "Available Controls",
+  "controlify.radial_menu.editor.equipped": "Equipped Actions",
+  "controlify.radial_menu.editor.page": "Page %s",
+  "controlify.radial_menu.editor.none": "No actions equipped",
+  "controlify.radial_menu.editor.none_available": "All controls are equipped",
+  "controlify.radial_menu.editor.add_to_end": "+ Add to end",
+  "controlify.radial_menu.editor.move_to_end": "Move to end",
+  "controlify.radial_menu.editor.remove": "Remove from radial menu",
+  "controlify.radial_menu.editor.missing": "Missing binding: %s",
   "controlify.radial.hotbar": "Slot %s",
   "controlify.radial.hotbar_save_hint": "Save your hotbar slots.",
   "controlify.radial.hotbar_load_hint": "Load your hotbar slots.",
diff --git a/src/main/resources/assets/controlify/radial_icons.json b/src/main/resources/assets/controlify/radial_icons.json
new file mode 100644
index 000000000..3057bd7c9
--- /dev/null
+++ b/src/main/resources/assets/controlify/radial_icons.json
@@ -0,0 +1,29 @@
+{
+  "controlify:jump": { "texture": "minecraft:mob_effect/jump_boost" },
+  "controlify:drop": { "model": "minecraft:barrier" },
+  "controlify:drop_stack": { "model": "minecraft:tnt" },
+  "controlify:pause": { "model": "minecraft:structure_void" },
+  "controlify:change_perspective": { "model": "minecraft:painting" },
+  "controlify:swap_hands": { "model": "minecraft:bone" },
+  "controlify:inventory": { "model": "minecraft:chest" },
+  "controlify:pick_block": { "model": "minecraft:stick" },
+  "controlify:pick_block_nbt": { "model": "minecraft:debug_stick" },
+  "controlify:open_chat": { "model": "minecraft:writable_book" },
+  "controlify:toggle_hud_visibility": { "texture": "minecraft:mob_effect/invisibility" },
+  "controlify:show_player_list": { "model": "minecraft:player_head" },
+  "controlify:take_screenshot": { "model": "minecraft:spyglass" },
+  "controlify:toggle_debug_menu": { "model": "minecraft:debug_stick" },
+  "controlify:toggle_debug_menu_fps": { "model": "minecraft:clock" },
+  "controlify:toggle_debug_menu_net": { "model": "minecraft:sculk_sensor" },
+  "controlify:toggle_debug_menu_prof": { "model": "minecraft:target" },
+  "controlify:debug_reload_chunks": { "model": "minecraft:compass" },
+  "controlify:debug_toggle_chunk_borders": { "model": "minecraft:filled_map" },
+  "controlify:debug_toggle_advanced_tooltips": { "model": "minecraft:writable_book" },
+  "controlify:debug_toggle_entity_hitboxes": { "model": "minecraft:zombie_head" },
+  "controlify:debug_reload_resource_packs": { "model": "minecraft:pink_dye" },
+  "controlify:debug_clear_chat": { "model": "minecraft:flint_and_steel" },
+  "controlify:debug_start_stop_profiling": { "model": "minecraft:structure_block" },
+  "voicechat:ptt_toggle": { "model": "minecraft:goat_horn" },
+  "voicechat:whisper_toggle": { "model": "minecraft:goat_horn" },
+  "voicechat:mute_microphone": { "model": "minecraft:barrier" }
+}
diff --git a/src/main/resources/controlify.mixins.json b/src/main/resources/controlify.mixins.json
index 17fde91bc..e35a22614 100644
--- a/src/main/resources/controlify.mixins.json
+++ b/src/main/resources/controlify.mixins.json
@@ -22,8 +22,10 @@
     "core.MouseHandlerMixin",
     "feature.accessibility.LocalPlayerMixin",
     "feature.bind.GuiAccessor",
+    "feature.bind.GuiGraphicsExtractorAccessor",
     "feature.bind.GuiMixin",
     "feature.bind.HudMixin",
+    "feature.bind.ItemStackRenderStateAccessor",
     "feature.bind.KeyMappingMixin",
     "feature.bind.ToggleKeyMappingMixin",
     "feature.font.FontAccessor",

From 511949f229a36906dbeed779925533dd99c541c6 Mon Sep 17 00:00:00 2001
From: Xander 
Date: Wed, 5 Aug 2026 20:51:19 +0100
Subject: [PATCH 2/2] Migrate old generated binding namespace in datafix and
 fix icon loading priority

---
 .../bindings/RadialIconManager.java           |  7 +-
 .../config/dto/dfu/ControlifyDataFixer.java   |  1 +
 .../dto/dfu/fixes/GeneratedBindingIdsFix.java | 87 +++++++++++++++++++
 3 files changed, 91 insertions(+), 4 deletions(-)
 create mode 100644 src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GeneratedBindingIdsFix.java

diff --git a/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java b/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java
index 26ff07d72..b9a668573 100644
--- a/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java
+++ b/src/main/java/dev/isxander/controlify/bindings/RadialIconManager.java
@@ -50,10 +50,9 @@ public CompletableFuture> load(ResourceManager manag
 			Map mappings = new HashMap<>();
 			List resources = manager.getResourceStack(DEFINITIONS);
 
-			// Resource stacks are returned from highest to lowest priority.
-			// Apply them in reverse so higher packs overwrite individual bindings.
-			for (int i = resources.size() - 1; i >= 0; i--) {
-				Resource resource = resources.get(i);
+			// Resource stacks are returned from lowest to highest priority, so later
+			// packs overwrite individual bindings from earlier packs.
+			for (Resource resource : resources) {
 				try (BufferedReader reader = resource.openAsReader()) {
 					JsonElement json = JsonParser.parseReader(reader);
 					CODEC.parse(JsonOps.INSTANCE, json)
diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java
index 6e5db6ebe..985576278 100644
--- a/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java
+++ b/src/main/java/dev/isxander/controlify/config/dto/dfu/ControlifyDataFixer.java
@@ -49,6 +49,7 @@ private static DataFixer createFixer() {
 		builder.addFixer(new GuideGuiScaleFix(v7, profileDefaults));
 
 		// v8
+		builder.addFixer(new GeneratedBindingIdsFix(v8));
 		builder.addFixer(new RadialMenuActionsFix(v8));
 
 		return builder.build().fixer();
diff --git a/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GeneratedBindingIdsFix.java b/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GeneratedBindingIdsFix.java
new file mode 100644
index 000000000..a37582f36
--- /dev/null
+++ b/src/main/java/dev/isxander/controlify/config/dto/dfu/fixes/GeneratedBindingIdsFix.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.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 java.util.LinkedHashMap;
+import java.util.Map;
+
+public final class GeneratedBindingIdsFix extends DataFix {
+	private static final String OLD_NAMESPACE = "fabric-key-binding-api-v1:";
+	private static final String NEW_NAMESPACE = "controlify_modded:";
+
+	public GeneratedBindingIdsFix(Schema outputSchema) {
+		super(outputSchema, true);
+	}
+
+	@Override
+	protected TypeRewriteRule makeRule() {
+		var profileType = getInputSchema().getType(ControlifyTypeReferences.PROFILE_CONFIG);
+
+		return fixTypeEverywhereTyped(
+				"Controlify: migrate generated binding IDs",
+				profileType,
+				typed -> typed.update(DSL.remainderFinder(), this::rewriteProfile)
+		);
+	}
+
+	private  Dynamic rewriteProfile(Dynamic root) {
+		return root.update("input", input -> input
+				.update("bindings", this::rewriteBindings)
+				.update("radial_menu", radialMenu -> radialMenu.update("actions", this::rewriteRadialActions))
+		);
+	}
+
+	private  Dynamic rewriteBindings(Dynamic bindings) {
+		return bindings.getMapValues().result()
+				.map(entries -> rewriteBindings(bindings, entries))
+				.orElse(bindings);
+	}
+
+	private  Dynamic rewriteBindings(Dynamic bindings, Map, Dynamic> entries) {
+		var rewritten = new LinkedHashMap, Dynamic>();
+
+		// Keep explicitly configured new IDs if both forms happen to exist.
+		entries.forEach((key, value) -> {
+			if (!isLegacyId(key)) {
+				rewritten.put(key, value);
+			}
+		});
+		entries.forEach((key, value) -> {
+			if (isLegacyId(key)) {
+				rewritten.putIfAbsent(rewriteId(key), value);
+			}
+		});
+
+		return bindings.createMap(rewritten);
+	}
+
+	private  Dynamic rewriteRadialActions(Dynamic actions) {
+		return actions.asStreamOpt().result()
+				.map(stream -> actions.createList(stream.map(this::rewriteId)))
+				.orElse(actions);
+	}
+
+	private boolean isLegacyId(Dynamic value) {
+		return value.asString().result()
+				.map(id -> id.startsWith(OLD_NAMESPACE))
+				.orElse(false);
+	}
+
+	private  Dynamic rewriteId(Dynamic value) {
+		return value.asString().result()
+				.filter(id -> id.startsWith(OLD_NAMESPACE))
+				.map(id -> value.createString(NEW_NAMESPACE + id.substring(OLD_NAMESPACE.length())))
+				.orElse(value);
+	}
+}