diff --git a/.gitignore b/.gitignore
index c476faf..1582394 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ bin/
# fabric
run/
+logs/
# java
@@ -38,3 +39,5 @@ hs_err_*.log
replay_*.log
*.hprof
*.jfr
+
+docs/
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..e9e3e2a
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,44 @@
+## Principles
+
+- Write production-ready, scalable, idiomatic code.
+- Prefer simple, maintainable solutions.
+- Optimize for correctness, readability, and performance.
+- Never over-engineer.
+
+## Code
+
+- No code comments unless requested.
+- Use descriptive names.
+- Reuse existing code before adding new abstractions.
+- Modify the smallest possible surface area.
+- Match the project's existing style.
+
+## Performance
+
+- Prefer efficient algorithms over micro-optimizations.
+- Avoid unnecessary allocations, work, and dependencies.
+- Measure before optimizing.
+
+## Workflow
+
+- Understand the codebase before making changes.
+- Search before creating.
+- Batch related edits.
+- Validate with the smallest relevant formatter, linter, typecheck, or tests.
+- Never guess APIs or project structure.
+
+## Tools
+
+- Minimize tool calls and tokens.
+- Read only the files needed.
+- Prefer targeted edits over full rewrites.
+- Keep responses concise unless more detail is requested.
+
+## Git
+
+- Use small, atomic commits.
+- Write clear conventional commit messages.
+- Never commit secrets, generated files, or broken code.
+- Use `gh` for GitHub operations whenever possible.
+- Inspect diffs before committing or opening a PR.
+- Never force-push or merge without explicit instruction.
\ No newline at end of file
diff --git a/README.md b/README.md
index 147fa2e..821291b 100644
--- a/README.md
+++ b/README.md
@@ -1,108 +1,60 @@
# keybinds
-A **fully client-side** Fabric mod for Minecraft **26.2** built around configurable **Use Item** binds and combat helpers.
+A **fully client-side** Fabric mod for Minecraft **26.2**: a tick-based macro engine with a full in-game macro editor.
-Each **Use Item** bind right-clicks any item you choose (anything with a right-click action) when one or more configurable triggers activate. Every bind can either **tap** once or **hold** right-click until released.
+Every macro pairs a composable **trigger** with an ordered list of tick-scheduled **actions**. Macros are stored in `config/macros.json`; press **Right Shift** in-game (or use the Mod Menu config button) to open the macro editor.
## Triggers
-Each bind has one **primary trigger** (chosen in the config UI):
+Triggers compose freely with **AND / OR / NOT** groups, nested to any depth:
-- **Keyboard key**
-- **Left-click** or **right-click**, optionally restricted to specific held item(s). Your normal click still happens—the item use is added on top.
+- **Key press** / **key release** — any GLFW key
+- **Mouse press** / **mouse release** — any mouse button
+- **Holding item** — the held item matches a list of ids
+- **Hotbar slot selected**
+- **Riding a mount**
+- **Held durability** — the held item's remaining durability drops to a threshold
+- **Player condition** — fires the moment it becomes true: low health, hurt, on fire, in lava, falling, in water, submerged, sneaking, sprinting
-Binds can also activate when an environment condition first becomes true:
+Key and click triggers are edge-based (they fire once per press, not every tick) and are only evaluated while their input is down, so idle macros cost nothing.
-- Low health
-- Hurt
-- On fire
-- In lava
-- Falling
-- In water
-- Submerged
-- Sneaking
-- Sprinting
+## Actions
-Additional options:
+Actions run in order within a tick until a **Wait** yields, giving tick-perfect sequences:
-- **Hotbar filter** — always fire, only while the item is on your hotbar, or only while it's buried in your inventory.
-- **Vanilla swap** — for buried items, visibly swap the item into your hand, use it, then swap it back (with configurable delays) instead of using an instant silent swap.
+- **Wait** — pause the macro for N ticks
+- **Use specific item** — locate an item (hand → hotbar → buried in inventory) and right-click it; optional hotbar gate, and a **vanilla swap** mode that visibly brings a buried item to hand, uses it, and swaps it back over configurable tick delays
+- **Use held item** / **Attack** (optionally swinging)
+- **Select hotbar slot** / **Restore starting slot**
+- **Select item on hotbar** — by id list or item tag
+- **Require item** — abort the macro unless the item is in the inventory
+- **Swap to item** / **Swap back** — inventory-wide swap, optionally ranking candidates by an enchantment level (e.g. the best **lunge** spear)
+- **Shield/totem offhand swap**
-### Tap vs Hold
+Classic combos like a mace stun slam (axe attack, wait 1 tick, mace attack) or a lunge spear swap are just short macros built from these actions.
-**Tap** performs a single right-click.
-
-Examples: ender pearls, wind charges, snowballs, eggs, splash potions, buckets, flint & steel, placing blocks, firing a loaded crossbow.
-
-**Hold** keeps right-click held until released.
-
-Examples: drawing a bow, loading a crossbow, eating food, spyglass, and other multi-tick items.
+Everything uses vanilla item-use and inventory packets, so **no server-side mod is required**. Works in singleplayer and vanilla multiplayer.
-## Combat helpers
+## Editor
-- **Swap Shield / Totem** *(default H)* — toggles your offhand between a shield and a Totem of Undying.
-- **Mace Stun Slam** *(default V)* — performs a two-tick axe → mace combo (requires both on your hotbar).
-- **Lunge Swap** *(unbound by default)* — swaps to your best spear (the one with the highest **lunge** enchantment level), left-clicks with it, then returns to your original slot. Triggers from a key **and/or** left-click **and/or** right-click, each optional, gated by a held-item filter (a specific item, any item, or an empty hand via `minecraft:air`). Configure the extra triggers, filter, spear ids, and enchantment id from the ⚙ next to its key.
+Right Shift opens the macro list: create, enable/disable, duplicate, and delete macros (right-click a row for the context menu). Inside a macro:
-Each helper has a **Vanilla** toggle: when on, its swap is spread over a few ticks so it looks like a manual slot change instead of an instant same-tick action. The shared tick spacing lives in `combatSwapToDelay` / `combatUseDelay` / `combatSwapBackDelay`.
+- searchable action palette and per-action parameter forms with key/mouse capture
+- a visual trigger tree editor for AND/OR/NOT nesting
+- drag **≡** to reorder actions, **Ctrl+click** to multi-select
+- **Ctrl+C / Ctrl+V** copy/paste (also across macros), **Ctrl+Z / Ctrl+Y** undo/redo, **Del** deletes the selection
-Everything uses vanilla item-use and inventory packets, so **no server-side mod is required**. Works in singleplayer and vanilla multiplayer.
+Changes are saved to `config/macros.json` when a screen closes.
## Dependencies
-Configuration is done through the Mod Menu config screen.
-
-- [Mod Menu v20.0.0+](https://modrinth.com/mod/modmenu)
+- [Mod Menu v20.0.0+](https://modrinth.com/mod/modmenu) *(optional)*
- [Fabric API v0.154.0+26.2](https://modrinth.com/mod/fabric-api)
- [Fabric Loader v0.19.3+](https://fabricmc.net)
-## How Use Item works
-
-When triggered, the mod searches for the configured item in this order:
-
-1. Main hand or offhand → use immediately.
-2. Hotbar → briefly switch to the slot, use it, then switch back.
-3. Main inventory → swap it into your selected slot, use it, then restore your inventory layout *(Tap mode only).*
-
-If the item isn't found, nothing happens.
-
-**Hold** mode only works from your hand or hotbar, since the item must stay selected while being held. It intentionally leaves the item selected afterward.
-
-Items that target blocks (placing blocks, buckets, flint & steel, etc.) still require looking at a valid block, just like vanilla.
-
## Configuration
-Settings are stored in `config/keybinds.json` (created on first launch). Editing it manually is optional—the Mod Menu UI is recommended.
-
-```json
-{
- "itemUses": [
- { "item": "minecraft:wind_charge", "hold": false, "key": 82 },
- { "item": "minecraft:ender_pearl", "hold": false, "key": 71 },
- { "item": "minecraft:white_wool", "hold": false, "key": 88 },
- { "item": "minecraft:bow", "hold": true, "key": 66 }
- ],
- "shieldTotemKey": 72,
- "maceStunSlamKey": 86,
- "returnToStartSlotAfterSlam": false,
- "shieldTotemVanilla": false,
- "maceStunSlamVanilla": false,
- "lungeSwapKey": -1,
- "lungeSwapLeftClick": false,
- "lungeSwapRightClick": false,
- "lungeSwapHeldItems": [],
- "spearItems": ["minecraft:wooden_spear", "minecraft:stone_spear", "minecraft:iron_spear", "minecraft:golden_spear", "minecraft:diamond_spear", "minecraft:netherite_spear"],
- "lungeEnchant": "minecraft:lunge",
- "lungeSwapVanilla": false,
- "combatSwapToDelay": 2,
- "combatUseDelay": 3,
- "combatSwapBackDelay": 2
-}
-```
-
-`key` values are raw [GLFW key codes](https://www.glfw.org/docs/latest/group__keys.html) (`82` = R, `71` = G, `72` = H, `86` = V), though you'll normally configure them through Mod Menu instead.
-
-Supports **up to 32 Use Item binds**.
+Macros live in `config/macros.json` (`formatVersion: 1`); triggers and actions are type-tagged JSON objects. Editing the file manually is optional — the in-game editor is recommended. Key values are raw [GLFW key codes](https://www.glfw.org/docs/latest/group__keys.html), captured for you by the editor.
## Building
@@ -122,4 +74,4 @@ To launch a development client:
## License
-Released under **CC0**. Feel free to learn from it or incorporate it into your own projects.
\ No newline at end of file
+Released under **CC0**. Feel free to learn from it or incorporate it into your own projects.
diff --git a/build.gradle b/build.gradle
index 545cb41..92a1792 100644
--- a/build.gradle
+++ b/build.gradle
@@ -7,12 +7,6 @@ version = project.mod_version
group = project.maven_group
repositories {
- // Add repositories to retrieve artifacts from in here.
- // You should only use this when depending on other mods because
- // Loom adds the essential maven repositories to download Minecraft and libraries from automatically.
- // See https://docs.gradle.org/current/userguide/declaring_repositories.html
- // for more information about repositories.
-
// Mod Menu is distributed through Modrinth's maven.
exclusiveContent {
forRepository {
@@ -25,18 +19,21 @@ repositories {
}
dependencies {
- // To change the versions see the gradle.properties file
minecraft "com.mojang:minecraft:${project.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
-
- // Fabric API. This is technically optional, but you probably want it anyway.
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
// Mod Menu — provides the in-game config button. Optional at runtime (the modmenu
// entrypoint only loads when it is present), but needed on the compile classpath.
- // 26.2 ships official mappings, so no remap configuration is needed — plain implementation
- // puts the (already officially-mapped) jar on the classpath and Fabric loads it in dev.
+ // 26.2 ships official mappings, so no remap configuration is needed.
implementation "maven.modrinth:modmenu:${project.modmenu_version}"
+
+ testImplementation "net.fabricmc:fabric-loader-junit:${project.loader_version}"
+ testImplementation "org.junit.jupiter:junit-jupiter:5.11.4"
+}
+
+test {
+ useJUnitPlatform()
}
processResources {
@@ -53,11 +50,7 @@ tasks.withType(JavaCompile).configureEach {
}
java {
- // Loom will automatically attach sourcesJar to a RemapSourcesJar task and to the "build" task
- // if it is present.
- // If you remove this line, sources will not be generated.
withSourcesJar()
-
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
@@ -71,19 +64,10 @@ jar {
}
}
-// configure the maven publication
publishing {
publications {
create("mavenJava", MavenPublication) {
from components.java
}
}
-
- // See https://docs.gradle.org/current/userguide/publishing_maven.html for information on how to set up publishing.
- repositories {
- // Add repositories to publish to here.
- // Notice: This block does NOT have the same function as the block in the top level.
- // The repositories here will be used for publishing your artifact, not for
- // retrieving dependencies.
- }
}
diff --git a/gradle.properties b/gradle.properties
index 9c55159..929969a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,5 +1,5 @@
# Done to increase the memory available to gradle.
-org.gradle.jvmargs=-Xmx1G
+org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=true
# IntelliJ IDEA is not yet fully compatible with configuration cache, see: https://github.com/FabricMC/fabric-loom/issues/1349
@@ -12,8 +12,8 @@ loader_version=0.19.3
loom_version=1.17-SNAPSHOT
# Mod Properties
-mod_version=1.1.0
-maven_group=com.example
+mod_version=2.1.0-beta.0
+maven_group=com.kingjayan
# Dependencies
fabric_api_version=0.154.0+26.2
diff --git a/src/main/java/com/example/BindEditScreen.java b/src/main/java/com/example/BindEditScreen.java
deleted file mode 100644
index 2910d37..0000000
--- a/src/main/java/com/example/BindEditScreen.java
+++ /dev/null
@@ -1,331 +0,0 @@
-package com.example;
-
-import com.mojang.blaze3d.platform.InputConstants;
-import net.minecraft.client.gui.components.AbstractWidget;
-import net.minecraft.client.gui.components.Button;
-import net.minecraft.client.gui.components.CycleButton;
-import net.minecraft.client.gui.components.EditBox;
-import net.minecraft.client.gui.components.StringWidget;
-import net.minecraft.client.gui.components.Tooltip;
-import net.minecraft.client.gui.screens.Screen;
-import net.minecraft.network.chat.Component;
-import org.lwjgl.glfw.GLFW;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.IntConsumer;
-
-/**
- * Per-bind advanced editor, opened from the ⚙ button on a row of {@link KeybindsConfigScreen}. Every
- * option beyond "which item" lives here so the main list stays compact: the primary trigger — a key
- * or a left/right-click with a held-item filter (never both) — the additive environment
- * conditions, the hotbar gate, and the visible "vanilla swap" with its per-step tick delays. Each
- * control carries a small {@code ?} help button with a tooltip instead of an inline paragraph.
- *
- *
There are more rows than fit a short screen, and Mod Menu does not scroll the sub-screen for us,
- * so the body between the fixed title and the fixed {@code Done} button scrolls on its own: content is
- * laid out in a virtual coordinate space, then each widget is offset by {@link #scroll} and hidden when
- * it would fall outside the viewport (so it neither draws nor takes clicks there). 26.2 renders retained
- * (no immediate {@code GuiGraphics}), so scrolling is done purely by moving/hiding widgets — no custom
- * drawing. Changes write straight through to the shared config object; {@code Done} persists and returns.
- */
-public class BindEditScreen extends Screen {
-
- private static final int COLUMN_WIDTH = 320;
- private static final int ROW = 22;
- private static final int GAP = 4;
- private static final int HELP = 20;
-
- private final Screen parent;
- private final KeybindsConfig config;
- private final KeybindsConfig.ItemUse use;
-
- /** Scrollable body widgets and their virtual (pre-scroll) top-Y, kept parallel. */
- private final List body = new ArrayList<>();
- private final List bodyY = new ArrayList<>();
-
- /** When non-null, the next key press is captured for this setter (the key-mode bind button). */
- private IntConsumer keyCapture;
-
- private int left;
- private int viewTop;
- private int viewBottom;
- private int maxScroll;
- private int scroll;
-
- public BindEditScreen(Screen parent, KeybindsConfig config, int index) {
- super(Component.translatable("keybinds.edit.title", index + 1));
- this.parent = parent;
- this.config = config;
- this.use = config.itemUses.get(index);
- }
-
- @Override
- protected void init() {
- body.clear();
- bodyY.clear();
-
- left = this.width / 2 - COLUMN_WIDTH / 2;
- viewTop = 24;
- viewBottom = this.height - 32;
-
- int contentWidth = COLUMN_WIDTH - HELP - GAP;
- int y = 0;
-
- // Fixed header and footer (not scrolled).
- addRenderableWidget(new StringWidget(0, 6, this.width, 16, this.title, this.font));
- addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose())
- .bounds(this.width / 2 - 100, this.height - 26, 200, 20)
- .build());
-
- // --- Item -----------------------------------------------------------------------------
- EditBox item = new EditBox(this.font, left, 0, COLUMN_WIDTH, 20,
- Component.translatable("keybinds.config.item_id"));
- item.setMaxLength(128);
- item.setValue(use.item == null ? "" : use.item);
- item.setHint(Component.translatable("keybinds.config.item_id"));
- item.setResponder(t -> use.item = t);
- track(item, y);
- y += ROW;
-
- // --- Use style ------------------------------------------------------------------------
- row(y, CycleButton.onOffBuilder(use.hold)
- .create(left, 0, contentWidth, 20, Component.translatable("keybinds.config.hold"),
- (b, v) -> use.hold = v), "keybinds.edit.hold_help");
- y += ROW;
-
- // --- Primary trigger: key OR click ----------------------------------------------------
- track(new StringWidget(left, 0, COLUMN_WIDTH, 12,
- Component.translatable("keybinds.edit.triggers"), this.font), y);
- y += 14;
-
- row(y, CycleButton.builder(this::modeLabel, normalizedMode())
- .withValues(KeybindsConfig.TRIGGER_KEY, KeybindsConfig.TRIGGER_LEFT_CLICK, KeybindsConfig.TRIGGER_RIGHT_CLICK)
- .create(left, 0, contentWidth, 20, Component.translatable("keybinds.edit.trigger_mode"),
- (b, v) -> {
- use.triggerMode = v;
- rebuildWidgets();
- }), "keybinds.edit.trigger_mode_help");
- y += ROW;
-
- // Only the relevant follow-up field for the chosen mode: the key, or the held-item filter.
- if (normalizedMode().equals(KeybindsConfig.TRIGGER_KEY)) {
- row(y, Button.builder(keyButtonLabel(), b -> beginCapture(code -> use.key = code))
- .bounds(left, 0, contentWidth, 20)
- .build(), "keybinds.edit.key_help");
- y += ROW;
- } else {
- EditBox clickItems = new EditBox(this.font, left, 0, contentWidth, 20,
- Component.translatable("keybinds.edit.click_items"));
- clickItems.setMaxLength(512);
- clickItems.setValue(String.join(", ", use.clickItems));
- clickItems.setHint(Component.translatable("keybinds.edit.click_items_hint"));
- clickItems.setResponder(t -> {
- List ids = new ArrayList<>();
- for (String part : t.split(",")) {
- String id = part.trim();
- if (!id.isBlank()) {
- ids.add(id);
- }
- }
- use.clickItems = ids;
- });
- row(y, clickItems, "keybinds.edit.click_items_help");
- y += ROW;
- }
-
- // --- Conditions (additive) ------------------------------------------------------------
- track(new StringWidget(left, 0, COLUMN_WIDTH - HELP, 12,
- Component.translatable("keybinds.edit.conditions"), this.font), y);
- help(left + COLUMN_WIDTH - HELP, y - 4, "keybinds.edit.conditions_help");
- y += 14;
-
- int cols = 3;
- int cellWidth = (COLUMN_WIDTH - (cols - 1) * GAP) / cols;
- for (int i = 0; i < KeybindsConfig.CONDITIONS.size(); i++) {
- String id = KeybindsConfig.CONDITIONS.get(i);
- int cx = left + (i % cols) * (cellWidth + GAP);
- int cy = y + (i / cols) * ROW;
- track(conditionButton(cx, cellWidth, id), cy);
- }
- y += ROW * ((KeybindsConfig.CONDITIONS.size() + cols - 1) / cols);
-
- // --- Gating & vanilla swap ------------------------------------------------------------
- row(y, CycleButton.builder(this::gateLabel, normalizedGate())
- .withValues(KeybindsConfig.GATE_ANY, KeybindsConfig.GATE_IN_HOTBAR, KeybindsConfig.GATE_NOT_IN_HOTBAR)
- .create(left, 0, contentWidth, 20, Component.translatable("keybinds.edit.gate"),
- (b, v) -> use.hotbarGate = v), "keybinds.edit.gate_help");
- y += ROW;
-
- row(y, CycleButton.onOffBuilder(use.vanillaSwap)
- .create(left, 0, contentWidth, 20, Component.translatable("keybinds.edit.vanilla_swap"),
- (b, v) -> use.vanillaSwap = v), "keybinds.edit.vanilla_swap_help");
- y += ROW;
-
- // --- Swap tick delays -----------------------------------------------------------------
- int labelWidth = 92;
- track(new StringWidget(left, 0, labelWidth, 20,
- Component.translatable("keybinds.edit.delays"), this.font), y);
- int dx = left + labelWidth + GAP;
- int delayWidth = 40;
- track(delayBox(dx, use.swapToDelay, v -> use.swapToDelay = v), y);
- dx += delayWidth + GAP;
- track(delayBox(dx, use.useDelay, v -> use.useDelay = v), y);
- dx += delayWidth + GAP;
- track(delayBox(dx, use.swapBackDelay, v -> use.swapBackDelay = v), y);
- help(left + COLUMN_WIDTH - HELP, y, "keybinds.edit.delays_help");
- y += ROW;
-
- int contentBottom = y;
- maxScroll = Math.max(0, contentBottom - (viewBottom - viewTop));
- relayout();
- }
-
- // --- Scrolling -----------------------------------------------------------------------------
-
- /** Register a body widget at virtual top-Y {@code y}, so {@link #relayout()} can scroll and clip it. */
- private T track(T widget, int y) {
- body.add(widget);
- bodyY.add(y);
- addRenderableWidget(widget);
- return widget;
- }
-
- /** Offset every body widget by the current scroll, hiding any that fall outside the viewport. */
- private void relayout() {
- scroll = Math.max(0, Math.min(scroll, maxScroll));
- for (int i = 0; i < body.size(); i++) {
- AbstractWidget widget = body.get(i);
- int wy = viewTop + bodyY.get(i) - scroll;
- widget.setY(wy);
- // Only show rows fully inside the viewport, so nothing draws over the title/footer and no
- // off-screen control can be clicked.
- widget.visible = wy >= viewTop && wy + widget.getHeight() <= viewBottom;
- }
- }
-
- @Override
- public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) {
- if (maxScroll > 0 && scrollY != 0) {
- scroll = Math.max(0, Math.min(scroll - (int) Math.signum(scrollY) * ROW, maxScroll));
- this.setFocused(null);
- relayout();
- return true;
- }
- return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY);
- }
-
- // --- Widget builders -----------------------------------------------------------------------
-
- /** Add a control plus a trailing {@code ?} help button sharing its virtual row. */
- private void row(int y, AbstractWidget control, String helpKey) {
- track(control, y);
- help(left + COLUMN_WIDTH - HELP, y, helpKey);
- }
-
- /** A small {@code ?} button whose only purpose is to show {@code helpKey}'s tooltip on hover. */
- private void help(int x, int y, String helpKey) {
- Button helpButton = Button.builder(Component.literal("?"), b -> {})
- .bounds(x, 0, HELP, 20)
- .build();
- helpButton.setTooltip(Tooltip.create(Component.translatable(helpKey)));
- track(helpButton, y);
- }
-
- /** A toggle button for one condition, checkmarked when the bind lists it. */
- private Button conditionButton(int x, int w, String id) {
- Button button = Button.builder(conditionLabel(id), b -> {
- if (use.conditions.contains(id)) {
- use.conditions.remove(id);
- } else {
- use.conditions.add(id);
- }
- rebuildWidgets();
- })
- .bounds(x, 0, w, 20)
- .build();
- button.setTooltip(Tooltip.create(Component.translatable("keybinds.cond." + id)));
- return button;
- }
-
- private Component conditionLabel(String id) {
- Component name = Component.translatable("keybinds.cond." + id);
- return use.conditions.contains(id) ? Component.literal("✔ ").append(name) : name;
- }
-
- /** Numeric tick-delay box: keeps only digits and clamps to 0..40 as it is typed. */
- private EditBox delayBox(int x, int value, IntConsumer setter) {
- EditBox box = new EditBox(this.font, x, 0, 40, 20, Component.translatable("keybinds.edit.delays"));
- box.setMaxLength(2);
- box.setValue(Integer.toString(value));
- // 26.2 dropped EditBox.setFilter, so keep only digits and clamp straight from the responder.
- box.setResponder(t -> {
- String digits = t.replaceAll("\\D", "");
- int parsed = digits.isBlank() ? 0 : Integer.parseInt(digits);
- setter.accept(Math.max(0, Math.min(parsed, 40)));
- });
- return box;
- }
-
- // --- Key capture ---------------------------------------------------------------------------
-
- private Component keyButtonLabel() {
- Component prefix = Component.translatable("keybinds.edit.key").copy().append(Component.literal(": "));
- Component name = KeybindsConfig.isBound(use.key)
- ? InputConstants.Type.KEYSYM.getOrCreate(use.key).getDisplayName()
- : Component.translatable("keybinds.config.unbound");
- return prefix.copy().append(name);
- }
-
- private void beginCapture(IntConsumer setter) {
- this.setFocused(null);
- this.keyCapture = setter;
- }
-
- @Override
- public boolean keyPressed(net.minecraft.client.input.KeyEvent event) {
- if (keyCapture != null) {
- // Escape clears the bind; any other key becomes the new binding.
- int assigned = event.key() == GLFW.GLFW_KEY_ESCAPE ? KeybindsConfig.UNBOUND : event.key();
- keyCapture.accept(assigned);
- keyCapture = null;
- rebuildWidgets();
- return true;
- }
- return super.keyPressed(event);
- }
-
- // --- Label / value helpers -----------------------------------------------------------------
-
- private String normalizedMode() {
- String m = use.triggerMode;
- if (KeybindsConfig.TRIGGER_LEFT_CLICK.equals(m) || KeybindsConfig.TRIGGER_RIGHT_CLICK.equals(m)) {
- return m;
- }
- return KeybindsConfig.TRIGGER_KEY;
- }
-
- private Component modeLabel(String mode) {
- return Component.translatable("keybinds.mode." + mode);
- }
-
- private String normalizedGate() {
- String g = use.hotbarGate;
- if (KeybindsConfig.GATE_IN_HOTBAR.equals(g) || KeybindsConfig.GATE_NOT_IN_HOTBAR.equals(g)) {
- return g;
- }
- return KeybindsConfig.GATE_ANY;
- }
-
- private Component gateLabel(String gate) {
- return Component.translatable("keybinds.gate." + gate);
- }
-
- @Override
- public void onClose() {
- config.save();
- if (this.minecraft != null) {
- this.minecraft.setScreenAndShow(parent);
- }
- }
-}
diff --git a/src/main/java/com/example/Keybinds.java b/src/main/java/com/example/Keybinds.java
deleted file mode 100644
index d7ea16a..0000000
--- a/src/main/java/com/example/Keybinds.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package com.example;
-
-import net.fabricmc.api.ModInitializer;
-
-import net.minecraft.resources.Identifier;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class Keybinds implements ModInitializer {
- public static final String MOD_ID = "keybinds";
-
- // This logger is used to write text to the console and the log file.
- // It is considered best practice to use your mod id as the logger's name.
- // That way, it's clear which mod wrote info, warnings, and errors.
- public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
-
- @Override
- public void onInitialize() {
- // This code runs as soon as Minecraft is in a mod-load-ready state.
- // However, some things (like resources) may still be uninitialized.
- // Proceed with mild caution.
-
- LOGGER.info("keybinds initialized (client-side convenience keybinds)");
- }
-
- public static Identifier id(String path) {
- return Identifier.fromNamespaceAndPath(MOD_ID, path);
- }
-}
diff --git a/src/main/java/com/example/KeybindsClient.java b/src/main/java/com/example/KeybindsClient.java
deleted file mode 100644
index 3f40f9a..0000000
--- a/src/main/java/com/example/KeybindsClient.java
+++ /dev/null
@@ -1,925 +0,0 @@
-package com.example;
-
-import com.mojang.blaze3d.platform.InputConstants;
-import com.mojang.blaze3d.platform.Window;
-import net.fabricmc.api.ClientModInitializer;
-import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
-import net.minecraft.client.Minecraft;
-import net.minecraft.client.multiplayer.MultiPlayerGameMode;
-import net.minecraft.client.player.LocalPlayer;
-import net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket;
-import net.minecraft.resources.Identifier;
-import net.minecraft.tags.ItemTags;
-import net.minecraft.world.InteractionHand;
-import net.minecraft.world.InteractionResult;
-import net.minecraft.world.entity.Entity;
-import net.minecraft.world.entity.player.Inventory;
-import net.minecraft.world.inventory.ContainerInput;
-import net.minecraft.world.inventory.InventoryMenu;
-import net.minecraft.world.item.Item;
-import net.minecraft.world.item.ItemStack;
-import net.minecraft.world.item.Items;
-import net.minecraft.world.item.enchantment.ItemEnchantments;
-import net.minecraft.world.phys.BlockHitResult;
-import net.minecraft.world.phys.HitResult;
-import org.lwjgl.glfw.GLFW;
-
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Client entrypoint. Drives the player's own inventory and item use through the normal item-use /
- * container packets (exactly what happens when you right-click an item, press a hotbar number, or
- * press the offhand-swap key). Nothing here is special-cased server-side, so it all works both in
- * singleplayer and on unmodified multiplayer servers.
- *
- * Unlike a typical mod, none of these keys are registered as vanilla {@link net.minecraft.client.KeyMapping}
- * mappings. Instead every key — the user-defined Use Item slots and the two combat helpers —
- * is stored in {@link KeybindsConfig} and polled here each tick via
- * {@link InputConstants#isKeyDown}. That is what lets the {@code Use Item} list be fully dynamic:
- * slots are added, removed, and rebound live from the config screen with no game restart and no
- * entries in Options → Controls. All key assignment happens in the mod's own screen
- * (opened from Mod Menu).
- */
-public class KeybindsClient implements ClientModInitializer {
-
- /** Container indices 0..8 are the hotbar; the main inventory is 9..35. */
- private static final int MAIN_INVENTORY_END = 36;
- /** {@code buttonNum} value that makes a {@link ContainerInput#SWAP} target the offhand. */
- private static final int OFFHAND_SWAP_BUTTON = 40;
-
- /** Sentinel for "no slot" used by the slam and hold state below. */
- private static final int NONE = -1;
-
- /**
- * Offsets that let mouse buttons and per-bind condition state share {@link #downLastTick}'s
- * edge-detection with real GLFW key codes without colliding (key codes are well under these).
- */
- private static final int MOUSE_BASE = 1_000_000;
- private static final int COND_BASE = 2_000_000;
-
- /**
- * Second-tick state of the mace stun-slam. {@link #pendingMaceSlot} is the mace hotbar slot to
- * strike with on the follow-up tick (or {@link #NONE}); {@link #slamReturnSlot} is the slot to
- * optionally switch back to afterwards.
- */
- private int slamReturnSlot = NONE;
- private int pendingMaceSlot = NONE;
-
- /** Index of the {@code itemUses} hold-mode bind currently being held, or {@link #NONE}. */
- private int activeHoldIndex = NONE;
-
- /**
- * Timed steps still to run, for the visible "vanilla swap" sequence (swap-to, use, swap-back) whose
- * spacing is user-configurable in ticks. Ticked every client tick regardless of screen state so an
- * in-progress swap always completes and never strands the item off its slot.
- */
- private final List pending = new ArrayList<>();
-
- /** A step scheduled to run after {@code ticks} more client ticks. */
- private static final class Pending {
- int ticks;
- final Runnable action;
-
- Pending(int ticks, Runnable action) {
- this.ticks = ticks;
- this.action = action;
- }
- }
-
- /** Our bound key codes that were held down on the previous polling tick, for edge detection. */
- private final Set downLastTick = new HashSet<>();
- /**
- * When true, the next polling tick only records key state without firing. Set whenever we stop
- * polling (a screen is open, or there is no player) so a key already held on return to gameplay
- * is not mistaken for a fresh press.
- */
- private boolean resyncNextTick = true;
-
- @Override
- public void onInitializeClient() {
- ClientTickEvents.END_CLIENT_TICK.register(client -> {
- // Run any scheduled swap steps first so their timing is independent of whether a screen is
- // open; a vanilla-swap sequence started in-world must still finish and put the item back.
- runPending();
-
- // The mace stun-slam spans two ticks: finish any slam started last tick first, so its
- // mace hit lands on the tick right after the axe hit.
- finishMaceStunSlam(client);
-
- // Only poll while actually in-world with no screen open. The grabbed mouse is the signal
- // for genuine first-person play: it releases whenever any GUI opens (so binding a key in
- // the config screen can't also trigger a use) or the window loses focus.
- if (client.player == null || client.level == null || !client.mouseHandler.isMouseGrabbed()) {
- if (activeHoldIndex != NONE) {
- releaseHold(client);
- }
- downLastTick.clear();
- resyncNextTick = true;
- return;
- }
-
- pollInputs(client, KeybindsConfig.get());
- });
- }
-
- // --- Key polling ---------------------------------------------------------------------------
-
- /**
- * Reads every configured key straight from GLFW and acts on fresh presses. Tap-mode use binds
- * fire one right-click per press; hold-mode binds engage vanilla's use key while held so
- * multi-tick actions (drawing a bow, loading a crossbow, eating) run to completion. The two combat
- * helpers fire on press. A key counts as "just pressed" only on the tick it goes down.
- */
- private void pollInputs(Minecraft client, KeybindsConfig config) {
- Window window = client.getWindow();
- List uses = config.itemUses;
- int count = Math.min(uses.size(), KeybindsConfig.MAX_ITEM_USES);
-
- // Continue or end an in-progress hold before considering new presses.
- if (activeHoldIndex != NONE) {
- KeybindsConfig.ItemUse held = activeHoldIndex < count ? uses.get(activeHoldIndex) : null;
- boolean stillHeld = held != null && held.hold && KeybindsConfig.isBound(held.key)
- && InputConstants.isKeyDown(window, held.key);
- if (stillHeld) {
- client.options.keyUse.setDown(true);
- } else {
- releaseHold(client);
- }
- }
-
- Set downNow = new HashSet<>();
-
- for (int i = 0; i < count; i++) {
- KeybindsConfig.ItemUse use = uses.get(i);
-
- // Each enabled trigger is always polled (so its held state is recorded for next tick's edge
- // detection) but only fires when it freshly transitions to down and we are not resyncing.
- boolean fire = false;
-
- String mode = use.triggerMode == null ? KeybindsConfig.TRIGGER_KEY : use.triggerMode;
-
- // 1. Primary trigger: EITHER the key OR a click — never both. Hold mode is key-only: a held
- // key drives a multi-tick use; a click has no sustained input, so it does a single use.
- if (mode.equals(KeybindsConfig.TRIGGER_KEY)) {
- if (KeybindsConfig.isBound(use.key)) {
- boolean pressed = isJustPressed(window, use.key, downNow);
- if (!resyncNextTick && pressed) {
- if (use.hold) {
- if (activeHoldIndex == NONE) {
- Item item = KeybindsConfig.resolveItem(use.item);
- if (item != null) {
- startHold(client, i, item);
- }
- }
- } else {
- fire = true;
- }
- }
- }
- } else {
- boolean rightClick = mode.equals(KeybindsConfig.TRIGGER_RIGHT_CLICK);
- int button = rightClick ? GLFW.GLFW_MOUSE_BUTTON_RIGHT : GLFW.GLFW_MOUSE_BUTTON_LEFT;
- boolean pressed = isMouseJustPressed(window, button, downNow);
- if (!resyncNextTick && pressed && holdingClickItem(client, use)) {
- // Right-click is vanilla's own use key: if the bind's item is already in the main
- // hand, vanilla just used it on this very click, so firing again would double it
- // (two pearls per click, etc.). Left-click (attack/mine) never uses an item, so it
- // always needs our use.
- if (!(rightClick && usedItemInMainHand(client, use))) {
- fire = true;
- }
- }
- }
-
- // 2. Environment-condition triggers, fired on the onset of any listed condition (additive).
- if (use.conditions != null && !use.conditions.isEmpty()) {
- boolean active = false;
- for (String id : use.conditions) {
- if (conditionActive(client, id)) {
- active = true;
- break;
- }
- }
- int code = COND_BASE + i;
- if (active) {
- downNow.add(code);
- }
- if (!resyncNextTick && active && !downLastTick.contains(code)) {
- fire = true;
- }
- }
-
- if (fire) {
- fireBind(client, use);
- }
- }
-
- if (KeybindsConfig.isBound(config.shieldTotemKey)
- && isJustPressed(window, config.shieldTotemKey, downNow) && !resyncNextTick) {
- if (config.shieldTotemVanilla) {
- pending.add(new Pending(config.combatSwapToDelay, () -> trySwapShieldTotem(client)));
- } else {
- trySwapShieldTotem(client);
- }
- }
- if (KeybindsConfig.isBound(config.maceStunSlamKey)
- && isJustPressed(window, config.maceStunSlamKey, downNow) && !resyncNextTick) {
- startMaceStunSlam(client);
- }
-
- // Lunge swap: fires from a key and/or a left/right-click, gated by the held-item filter. Every
- // enabled trigger is polled unconditionally (so its edge state is recorded) but only the ones
- // that just went down set the flag, and only outside a resync with the filter passing act.
- boolean lungeFire = false;
- if (KeybindsConfig.isBound(config.lungeSwapKey)
- && isJustPressed(window, config.lungeSwapKey, downNow)) {
- lungeFire = true;
- }
- if (config.lungeSwapLeftClick
- && isMouseJustPressed(window, GLFW.GLFW_MOUSE_BUTTON_LEFT, downNow)) {
- lungeFire = true;
- }
- if (config.lungeSwapRightClick
- && isMouseJustPressed(window, GLFW.GLFW_MOUSE_BUTTON_RIGHT, downNow)) {
- lungeFire = true;
- }
- if (lungeFire && !resyncNextTick && holdingLungeItem(client, config)) {
- startLungeSwap(client, config);
- }
-
- downLastTick.clear();
- downLastTick.addAll(downNow);
- resyncNextTick = false;
- }
-
- /**
- * Whether {@code key} transitioned to down this tick. Records the key in {@code downNow} when it is
- * currently held so {@link #downLastTick} can be rebuilt for next tick's edge detection.
- */
- private boolean isJustPressed(Window window, int key, Set downNow) {
- boolean down = InputConstants.isKeyDown(window, key);
- if (down) {
- downNow.add(key);
- }
- return down && !downLastTick.contains(key);
- }
-
- /** Whether {@code button} (a GLFW mouse button) transitioned to down this tick, tracked like a key. */
- private boolean isMouseJustPressed(Window window, int button, Set downNow) {
- boolean down = GLFW.glfwGetMouseButton(window.handle(), button) == GLFW.GLFW_PRESS;
- int code = MOUSE_BASE + button;
- if (down) {
- downNow.add(code);
- }
- return down && !downLastTick.contains(code);
- }
-
- /** Whether the bind's target item is in the main hand — i.e. a right-click already used it via vanilla. */
- private static boolean usedItemInMainHand(Minecraft client, KeybindsConfig.ItemUse use) {
- LocalPlayer player = client.player;
- Item item = KeybindsConfig.resolveItem(use.item);
- return player != null && item != null && player.getMainHandItem().is(item);
- }
-
- /** Whether the player's main hand holds one of the bind's click items (an empty list means any item). */
- private static boolean holdingClickItem(Minecraft client, KeybindsConfig.ItemUse use) {
- LocalPlayer player = client.player;
- if (player == null) {
- return false;
- }
- if (use.clickItems == null || use.clickItems.isEmpty()) {
- return true;
- }
- ItemStack held = player.getMainHandItem();
- for (String id : use.clickItems) {
- Item item = KeybindsConfig.resolveItem(id);
- if (item != null && held.is(item)) {
- return true;
- }
- }
- return false;
- }
-
- /** Evaluate one environment-condition id against the local player. Thresholds are fixed presets. */
- private static boolean conditionActive(Minecraft client, String id) {
- LocalPlayer player = client.player;
- if (player == null) {
- return false;
- }
- return switch (id) {
- case "low_health" -> player.getHealth() <= 6.0f; // 3 hearts or less
- case "on_fire" -> player.isOnFire();
- case "in_lava" -> player.isInLava();
- case "falling" -> player.fallDistance > 3.0f; // past roughly safe fall distance
- case "in_water" -> player.isInWater();
- case "submerged" -> player.isUnderWater();
- case "hurt" -> player.hurtTime > 0;
- case "sneaking" -> player.isShiftKeyDown();
- case "sprinting" -> player.isSprinting();
- default -> false;
- };
- }
-
- // --- Scheduled steps -----------------------------------------------------------------------
-
- /** Run and drop every scheduled step whose countdown has elapsed; decrement the rest. */
- private void runPending() {
- if (pending.isEmpty()) {
- return;
- }
- Iterator it = pending.iterator();
- while (it.hasNext()) {
- Pending step = it.next();
- if (step.ticks <= 0) {
- step.action.run();
- it.remove();
- } else {
- step.ticks--;
- }
- }
- }
-
- // --- Use-item binds ------------------------------------------------------------------------
-
- /**
- * Fire a bind's use once, honoring its hotbar gate and vanilla-swap preference. The item is located
- * (hands / hotbar / buried in the main inventory); the gate can veto the fire based on that location;
- * a buried item with {@code vanillaSwap} runs the visible timed swap, otherwise the instant path.
- */
- private void fireBind(Minecraft client, KeybindsConfig.ItemUse use) {
- LocalPlayer player = client.player;
- MultiPlayerGameMode gameMode = client.gameMode;
- if (player == null || gameMode == null) {
- return;
- }
- Item item = KeybindsConfig.resolveItem(use.item);
- if (item == null) {
- return;
- }
-
- Inventory inventory = player.getInventory();
- boolean inMainHand = player.getMainHandItem().is(item);
- boolean inHands = inMainHand || player.getOffhandItem().is(item);
- int hotbarSlot = findHotbarSlot(inventory, item);
- boolean onHotbar = inHands || hotbarSlot >= 0;
-
- String gate = use.hotbarGate == null ? KeybindsConfig.GATE_ANY : use.hotbarGate;
- if (gate.equals(KeybindsConfig.GATE_IN_HOTBAR) && !onHotbar) {
- return;
- }
- if (gate.equals(KeybindsConfig.GATE_NOT_IN_HOTBAR) && onHotbar) {
- return;
- }
-
- // A buried item can be used the fast (silent) way or the visible legit-looking way.
- int buriedSlot = NONE;
- if (!onHotbar && player.containerMenu == player.inventoryMenu) {
- for (int slot = Inventory.SELECTION_SIZE; slot < MAIN_INVENTORY_END; slot++) {
- if (inventory.getItem(slot).is(item)) {
- buriedSlot = slot;
- break;
- }
- }
- }
-
- // Vanilla swap makes the whole thing look manual and tick-spaced instead of instant/silent:
- // - buried in the inventory: bring it to the held slot, right-click, swap it back;
- // - sitting on the hotbar (but not already in hand): visibly switch to that slot, right-click,
- // switch back — the same slot flick useItemInstant does, but spread over ticks so it shows.
- // An item already in a hand needs no switch, so it just uses instantly.
- if (use.vanillaSwap && buriedSlot != NONE) {
- scheduleVanillaSwap(client, use, buriedSlot);
- } else if (use.vanillaSwap && !inMainHand && hotbarSlot >= 0) {
- scheduleVanillaHotbarUse(client, use, hotbarSlot);
- } else {
- useItemInstant(client, item);
- }
- }
-
- /**
- * Schedule a visible hotbar-slot switch-use-switch for an item already on the hotbar: move the
- * selection to its slot, right-click after the use delay, then switch back to the original slot —
- * with the user's tick gaps between each step so it reads as a real slot change rather than an
- * instant same-tick flick.
- */
- private void scheduleVanillaHotbarUse(Minecraft client, KeybindsConfig.ItemUse use, int hotbarSlot) {
- LocalPlayer player = client.player;
- if (player == null) {
- return;
- }
- int previousSlot = player.getInventory().getSelectedSlot();
-
- int atSwapTo = use.swapToDelay;
- int atUse = atSwapTo + use.useDelay;
- int atSwapBack = atUse + use.swapBackDelay;
-
- pending.add(new Pending(atSwapTo, () -> selectHotbarSlotIfPresent(client, hotbarSlot)));
- pending.add(new Pending(atUse, () -> doInstantUse(client, InteractionHand.MAIN_HAND)));
- pending.add(new Pending(atSwapBack, () -> selectHotbarSlotIfPresent(client, previousSlot)));
- }
-
- /** Switch the held hotbar slot from a scheduled step, guarding against the player having gone away. */
- private static void selectHotbarSlotIfPresent(Minecraft client, int slot) {
- LocalPlayer player = client.player;
- if (player == null || slot < 0 || slot >= Inventory.SELECTION_SIZE) {
- return;
- }
- selectHotbarSlot(client, player.getInventory(), slot);
- }
-
- /**
- * Schedule the visible three-step swap for an inventory-buried item: bring it to the held hotbar
- * slot, right-click, then swap it back — with the user's configured tick gaps between each step so
- * it reads as a genuine manual swap-use-swap rather than an instant silent container edit.
- */
- private void scheduleVanillaSwap(Minecraft client, KeybindsConfig.ItemUse use, int buriedSlot) {
- LocalPlayer player = client.player;
- if (player == null || player.containerMenu != player.inventoryMenu) {
- return;
- }
- Inventory inventory = player.getInventory();
- int hotbarSlot = inventory.getSelectedSlot();
- int containerId = player.inventoryMenu.containerId;
-
- int atSwapTo = use.swapToDelay;
- int atUse = atSwapTo + use.useDelay;
- int atSwapBack = atUse + use.swapBackDelay;
-
- pending.add(new Pending(atSwapTo, () -> swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)));
- pending.add(new Pending(atUse, () -> doInstantUse(client, InteractionHand.MAIN_HAND)));
- pending.add(new Pending(atSwapBack, () -> swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)));
- }
-
- /** SWAP the main-inventory slot with the currently held hotbar slot, if the inventory menu is still open. */
- private static void swapBuriedWithHeld(Minecraft client, int containerId, int buriedSlot, int hotbarSlot) {
- LocalPlayer player = client.player;
- MultiPlayerGameMode gameMode = client.gameMode;
- if (player == null || gameMode == null || player.containerMenu != player.inventoryMenu) {
- return;
- }
- gameMode.handleContainerInput(containerId, buriedSlot, hotbarSlot, ContainerInput.SWAP, player);
- }
-
- /**
- * Single right-click of {@code item} wherever it can be found (a hand, then the hotbar, then the
- * main inventory). Mirrors vanilla: if a block is targeted the item is used against it (so blocks
- * place and buckets/flint&steel work), otherwise it is used in the air (throwables, eggs,
- * firing a loaded crossbow, ...).
- */
- private static void useItemInstant(Minecraft client, Item item) {
- LocalPlayer player = client.player;
- MultiPlayerGameMode gameMode = client.gameMode;
- if (player == null || gameMode == null) {
- return;
- }
-
- // 1. Already in a hand.
- for (InteractionHand hand : InteractionHand.values()) {
- if (player.getItemInHand(hand).is(item)) {
- doInstantUse(client, hand);
- return;
- }
- }
-
- Inventory inventory = player.getInventory();
-
- // 2. On the hotbar: briefly select that slot, use it, switch back.
- for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) {
- if (inventory.getItem(slot).is(item)) {
- int previousSlot = inventory.getSelectedSlot();
- selectHotbarSlot(client, inventory, slot);
- doInstantUse(client, InteractionHand.MAIN_HAND);
- selectHotbarSlot(client, inventory, previousSlot);
- return;
- }
- }
-
- // 3. Buried in the main inventory. Only reachable while no other container is open, since
- // we drive the player's own inventory menu. Swap onto the held slot, use, swap back.
- if (player.containerMenu == player.inventoryMenu) {
- int hotbarSlot = inventory.getSelectedSlot();
- int containerId = player.inventoryMenu.containerId;
- for (int slot = Inventory.SELECTION_SIZE; slot < MAIN_INVENTORY_END; slot++) {
- if (inventory.getItem(slot).is(item)) {
- gameMode.handleContainerInput(containerId, slot, hotbarSlot, ContainerInput.SWAP, player);
- doInstantUse(client, InteractionHand.MAIN_HAND);
- gameMode.handleContainerInput(containerId, slot, hotbarSlot, ContainerInput.SWAP, player);
- return;
- }
- }
- }
- }
-
- /** One right-click with the given hand, using a targeted block if there is one, else the air. */
- private static void doInstantUse(Minecraft client, InteractionHand hand) {
- LocalPlayer player = client.player;
- MultiPlayerGameMode gameMode = client.gameMode;
- if (player == null || gameMode == null) {
- return;
- }
-
- HitResult hit = client.hitResult;
- if (hit instanceof BlockHitResult blockHit && hit.getType() == HitResult.Type.BLOCK) {
- InteractionResult result = gameMode.useItemOn(player, hand, blockHit);
- if (result.consumesAction()) {
- player.swing(hand);
- return;
- }
- }
-
- InteractionResult result = gameMode.useItem(player, hand);
- if (result.consumesAction()) {
- player.swing(hand);
- }
- }
-
- /**
- * Begin a hold-mode use: select the item on the hotbar (or keep the current slot if it is
- * already held) and engage vanilla's use key, which then drives the whole multi-tick action.
- * Hold mode only works from the hotbar / main hand, since holding an item requires it selected.
- */
- private void startHold(Minecraft client, int index, Item item) {
- LocalPlayer player = client.player;
- if (player == null) {
- return;
- }
- Inventory inventory = player.getInventory();
-
- int slot = findHotbarSlot(inventory, item);
- if (slot < 0) {
- if (!player.getMainHandItem().is(item)) {
- return;
- }
- slot = inventory.getSelectedSlot();
- }
-
- selectHotbarSlot(client, inventory, slot);
- activeHoldIndex = index;
- client.options.keyUse.setDown(true);
- }
-
- /**
- * End a hold-mode use: release vanilla's use key so the action finishes (a drawn bow fires here).
- * The selected slot is intentionally left as-is — switching hotbar slots cancels an in-progress
- * use, which would eat the shot — so the player simply keeps holding the item.
- */
- private void releaseHold(Minecraft client) {
- activeHoldIndex = NONE;
- if (client.options != null) {
- client.options.keyUse.setDown(false);
- }
- }
-
- // --- Shield / totem swap -------------------------------------------------------------------
-
- /**
- * Toggles the offhand between a shield and a totem of undying:
- *
- * - If a shield is in the offhand, swap it for the first totem in the inventory.
- * If there is no totem, the shield is simply unequipped into the inventory.
- * - Otherwise (a totem, an unrelated item, or nothing is in the offhand), equip the
- * first shield found in the inventory, moving whatever was there back into the pack.
- *
- * Everything is driven through the player's own inventory menu, so it only runs while no other
- * container is open — but it does not require the inventory screen to be visually open.
- */
- private static void trySwapShieldTotem(Minecraft client) {
- LocalPlayer player = client.player;
- MultiPlayerGameMode gameMode = client.gameMode;
- if (player == null || gameMode == null) {
- return;
- }
- if (player.containerMenu != player.inventoryMenu) {
- return;
- }
-
- Inventory inventory = player.getInventory();
- int containerId = player.inventoryMenu.containerId;
- ItemStack offhand = player.getItemInHand(InteractionHand.OFF_HAND);
-
- if (offhand.is(Items.SHIELD)) {
- int totemSlot = findItem(inventory, Items.TOTEM_OF_UNDYING);
- if (totemSlot >= 0) {
- swapWithOffhand(gameMode, containerId, player, totemSlot);
- } else {
- gameMode.handleContainerInput(
- containerId, InventoryMenu.SHIELD_SLOT, 0, ContainerInput.QUICK_MOVE, player);
- }
- } else {
- int shieldSlot = findItem(inventory, Items.SHIELD);
- if (shieldSlot >= 0) {
- swapWithOffhand(gameMode, containerId, player, shieldSlot);
- }
- }
- }
-
- // --- Mace stun-slam ------------------------------------------------------------------------
-
- /**
- * First tick of the mace stun-slam: requires both a mace and an axe on the hotbar, then switches
- * to the axe and attacks in the same tick. The mace follow-up is left to
- * {@link #finishMaceStunSlam(Minecraft)} on the very next tick.
- */
- private void startMaceStunSlam(Minecraft client) {
- LocalPlayer player = client.player;
- MultiPlayerGameMode gameMode = client.gameMode;
- if (player == null || gameMode == null || pendingMaceSlot != NONE) {
- return;
- }
-
- Inventory inventory = player.getInventory();
- int axeSlot = findHotbarSlot(inventory, ItemTags.AXES);
- int maceSlot = findHotbarSlot(inventory, Items.MACE);
- if (axeSlot < 0 || maceSlot < 0) {
- return;
- }
-
- int returnSlot = inventory.getSelectedSlot();
- KeybindsConfig config = KeybindsConfig.get();
-
- // Vanilla mode spreads the axe hit, the mace hit, and the optional slot restore over the shared
- // combat tick delays so it reads as a manual two-swap combo rather than an instant same-tick flick.
- if (config.maceStunSlamVanilla) {
- int atAxe = config.combatSwapToDelay;
- int atMace = atAxe + config.combatUseDelay;
- int atBack = atMace + config.combatSwapBackDelay;
- pending.add(new Pending(atAxe, () -> {
- selectHotbarSlotIfPresent(client, axeSlot);
- attackCrosshairTarget(client);
- }));
- pending.add(new Pending(atMace, () -> {
- selectHotbarSlotIfPresent(client, maceSlot);
- attackCrosshairTarget(client);
- }));
- if (config.returnToStartSlotAfterSlam) {
- pending.add(new Pending(atBack, () -> selectHotbarSlotIfPresent(client, returnSlot)));
- }
- return;
- }
-
- // Fast path: axe hit now, mace hit on the very next tick (finishMaceStunSlam).
- slamReturnSlot = returnSlot;
- pendingMaceSlot = maceSlot;
-
- selectHotbarSlot(client, inventory, axeSlot);
- attackCrosshairTarget(client);
- }
-
- /**
- * Second tick of the mace stun-slam (the tick after {@link #startMaceStunSlam(Minecraft)}):
- * switch to the mace and attack again. Optionally restores the originally selected slot.
- */
- private void finishMaceStunSlam(Minecraft client) {
- if (pendingMaceSlot == NONE) {
- return;
- }
- int maceSlot = pendingMaceSlot;
- int returnSlot = slamReturnSlot;
- pendingMaceSlot = NONE;
- slamReturnSlot = NONE;
-
- LocalPlayer player = client.player;
- if (player == null || client.gameMode == null) {
- return;
- }
- Inventory inventory = player.getInventory();
-
- selectHotbarSlot(client, inventory, maceSlot);
- attackCrosshairTarget(client);
-
- if (KeybindsConfig.get().returnToStartSlotAfterSlam && returnSlot != NONE) {
- selectHotbarSlot(client, inventory, returnSlot);
- }
- }
-
- /** Attack whatever entity is currently under the crosshair, exactly like a left-click would. */
- private static void attackCrosshairTarget(Minecraft client) {
- LocalPlayer player = client.player;
- MultiPlayerGameMode gameMode = client.gameMode;
- if (player == null || gameMode == null) {
- return;
- }
- Entity target = client.crosshairPickEntity;
- if (target != null) {
- gameMode.attack(player, target);
- player.swing(InteractionHand.MAIN_HAND);
- }
- }
-
- // --- Lunge swap ----------------------------------------------------------------------------
-
- /** Whether the main hand passes the lunge held-item filter (empty list = any; {@code minecraft:air} = empty hand). */
- private static boolean holdingLungeItem(Minecraft client, KeybindsConfig config) {
- LocalPlayer player = client.player;
- if (player == null) {
- return false;
- }
- List ids = config.lungeSwapHeldItems;
- if (ids == null || ids.isEmpty()) {
- return true;
- }
- ItemStack held = player.getMainHandItem();
- for (String id : ids) {
- Item item = KeybindsConfig.resolveItem(id);
- if (item != null && held.is(item)) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Swap to the best spear on hand — the carried spear with the highest {@code lungeEnchant} level —
- * left-click with it, then return to the original slot. Prefers a spear already on the hotbar; falls
- * back to one buried in the main inventory. Vanilla mode spreads the switch-attack-switch over the
- * shared combat tick delays; otherwise it is an instant same-tick flick.
- */
- private void startLungeSwap(Minecraft client, KeybindsConfig config) {
- LocalPlayer player = client.player;
- if (player == null || client.gameMode == null) {
- return;
- }
- Inventory inventory = player.getInventory();
-
- // Highest-lunge spear across the whole inventory; ties keep the lower slot (hotbar before pack).
- int bestSlot = NONE;
- int bestLevel = Integer.MIN_VALUE;
- for (int slot = 0; slot < MAIN_INVENTORY_END; slot++) {
- ItemStack stack = inventory.getItem(slot);
- if (!isSpear(config, stack)) {
- continue;
- }
- int level = lungeLevel(config, stack);
- if (level > bestLevel) {
- bestLevel = level;
- bestSlot = slot;
- }
- }
- if (bestSlot == NONE) {
- return;
- }
-
- if (bestSlot < Inventory.SELECTION_SIZE) {
- lungeFromHotbar(client, config, bestSlot);
- } else {
- lungeFromBuried(client, config, bestSlot);
- }
- }
-
- /** Switch to a hotbar spear, attack, switch back — instantly, or tick-spaced in vanilla mode. */
- private void lungeFromHotbar(Minecraft client, KeybindsConfig config, int spearSlot) {
- LocalPlayer player = client.player;
- if (player == null) {
- return;
- }
- int previousSlot = player.getInventory().getSelectedSlot();
-
- if (!config.lungeSwapVanilla) {
- Inventory inventory = player.getInventory();
- selectHotbarSlot(client, inventory, spearSlot);
- lungeAttack(client);
- selectHotbarSlot(client, inventory, previousSlot);
- return;
- }
-
- int atSwapTo = config.combatSwapToDelay;
- int atUse = atSwapTo + config.combatUseDelay;
- int atSwapBack = atUse + config.combatSwapBackDelay;
- pending.add(new Pending(atSwapTo, () -> selectHotbarSlotIfPresent(client, spearSlot)));
- pending.add(new Pending(atUse, () -> lungeAttack(client)));
- pending.add(new Pending(atSwapBack, () -> selectHotbarSlotIfPresent(client, previousSlot)));
- }
-
- /** Bring a buried spear to the held slot, attack, swap it back — instantly, or tick-spaced in vanilla mode. */
- private void lungeFromBuried(Minecraft client, KeybindsConfig config, int buriedSlot) {
- LocalPlayer player = client.player;
- if (player == null || player.containerMenu != player.inventoryMenu) {
- return;
- }
- Inventory inventory = player.getInventory();
- int hotbarSlot = inventory.getSelectedSlot();
- int containerId = player.inventoryMenu.containerId;
-
- if (!config.lungeSwapVanilla) {
- swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot);
- lungeAttack(client);
- swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot);
- return;
- }
-
- int atSwapTo = config.combatSwapToDelay;
- int atUse = atSwapTo + config.combatUseDelay;
- int atSwapBack = atUse + config.combatSwapBackDelay;
- pending.add(new Pending(atSwapTo, () -> swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)));
- pending.add(new Pending(atUse, () -> lungeAttack(client)));
- pending.add(new Pending(atSwapBack, () -> swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot)));
- }
-
- /** A left-click with the main hand: attack whatever is under the crosshair (if any) and always swing. */
- private static void lungeAttack(Minecraft client) {
- LocalPlayer player = client.player;
- MultiPlayerGameMode gameMode = client.gameMode;
- if (player == null || gameMode == null) {
- return;
- }
- Entity target = client.crosshairPickEntity;
- if (target != null) {
- gameMode.attack(player, target);
- }
- player.swing(InteractionHand.MAIN_HAND);
- }
-
- /** Whether {@code stack} is a configured spear (or, if no ids are configured, any lunge-enchanted item). */
- private static boolean isSpear(KeybindsConfig config, ItemStack stack) {
- if (stack.isEmpty()) {
- return false;
- }
- List ids = config.spearItems;
- if (ids == null || ids.isEmpty()) {
- return lungeLevel(config, stack) > 0;
- }
- for (String id : ids) {
- Item item = KeybindsConfig.resolveItem(id);
- if (item != null && stack.is(item)) {
- return true;
- }
- }
- return false;
- }
-
- /** Level of the configured lunge enchantment on {@code stack}, or 0 if absent. */
- private static int lungeLevel(KeybindsConfig config, ItemStack stack) {
- if (stack.isEmpty() || config.lungeEnchant == null || config.lungeEnchant.isBlank()) {
- return 0;
- }
- Identifier target = Identifier.tryParse(config.lungeEnchant.trim());
- if (target == null) {
- return 0;
- }
- ItemEnchantments enchantments = stack.getEnchantments();
- for (var entry : enchantments.entrySet()) {
- if (entry.getKey().is(target)) {
- return entry.getIntValue();
- }
- }
- return 0;
- }
-
- // --- Inventory helpers ---------------------------------------------------------------------
-
- /** First hotbar slot (0..8) holding {@code item}, or -1. */
- private static int findHotbarSlot(Inventory inventory, Item item) {
- for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) {
- if (inventory.getItem(slot).is(item)) {
- return slot;
- }
- }
- return -1;
- }
-
- /** First hotbar slot (0..8) holding an item in {@code tag}, or -1. */
- private static int findHotbarSlot(Inventory inventory, net.minecraft.tags.TagKey- tag) {
- for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) {
- if (inventory.getItem(slot).is(tag)) {
- return slot;
- }
- }
- return -1;
- }
-
- /** First inventory index (hotbar 0..8, then main 9..35) holding {@code item}, or -1. */
- private static int findItem(Inventory inventory, Item item) {
- for (int slot = 0; slot < MAIN_INVENTORY_END; slot++) {
- if (inventory.getItem(slot).is(item)) {
- return slot;
- }
- }
- return -1;
- }
-
- /** Swap the stack at the given inventory container index with the offhand slot. */
- private static void swapWithOffhand(MultiPlayerGameMode gameMode, int containerId,
- LocalPlayer player, int containerIndex) {
- gameMode.handleContainerInput(
- containerId, menuSlotOf(containerIndex), OFFHAND_SWAP_BUTTON, ContainerInput.SWAP, player);
- }
-
- /** Map a player-inventory container index onto its {@link InventoryMenu} slot id. */
- private static int menuSlotOf(int containerIndex) {
- return containerIndex < Inventory.SELECTION_SIZE
- ? InventoryMenu.USE_ROW_SLOT_START + containerIndex
- : containerIndex;
- }
-
- /** Switch the held hotbar slot and tell the server, mirroring vanilla scroll/number-key selection. */
- private static void selectHotbarSlot(Minecraft client, Inventory inventory, int slot) {
- inventory.setSelectedSlot(slot);
- if (client.getConnection() != null) {
- client.getConnection().send(new ServerboundSetCarriedItemPacket(slot));
- }
- }
-}
diff --git a/src/main/java/com/example/KeybindsConfig.java b/src/main/java/com/example/KeybindsConfig.java
deleted file mode 100644
index c3ea89d..0000000
--- a/src/main/java/com/example/KeybindsConfig.java
+++ /dev/null
@@ -1,300 +0,0 @@
-package com.example;
-
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import net.fabricmc.loader.api.FabricLoader;
-import net.minecraft.core.registries.BuiltInRegistries;
-import net.minecraft.resources.Identifier;
-import net.minecraft.world.item.Item;
-import org.lwjgl.glfw.GLFW;
-
-import java.io.IOException;
-import java.io.Reader;
-import java.io.Writer;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Persistent, human-editable settings for the mod, and what the Mod Menu button opens.
- *
- *
The heart of it is {@link #itemUses}: a numbered, fully user-defined list of "Use Item" binds.
- * Each entry names an item (any item id with a right-click action — a throwable, a block to place, a
- * bow/crossbow, food, ...), whether the key should be tapped once or held, and — crucially — its own
- * key. Keys are stored here, not as vanilla key mappings, so the mod polls them itself. That
- * means slots are fully dynamic: adding, removing, or rebinding one takes effect immediately, with no
- * game restart and no entries cluttering Options → Controls.
- *
- *
The two combat helpers (shield/totem swap, mace stun-slam) work the same way — each has its own
- * bound key here and is active whenever that key is bound (see {@link #isBound(int)}).
- *
- *
The file lives at {@code config/keybinds.json}. Missing fields fall back to the defaults below,
- * so older files keep loading after updates.
- */
-public class KeybindsConfig {
-
- /** GLFW key value meaning "no key bound"; such a bind is inert. */
- public static final int UNBOUND = GLFW.GLFW_KEY_UNKNOWN;
-
- /**
- * Sanity cap on the number of use-item slots. Purely a UI/limit guard now — because keys are
- * polled rather than registered as vanilla mappings, slots are added and removed live with no
- * restart, so this only keeps the paged list bounded.
- */
- public static final int MAX_ITEM_USES = 32;
-
- /** Hotbar-gate values for {@link ItemUse#hotbarGate}: fire regardless, only-if-on-hotbar, only-if-not. */
- public static final String GATE_ANY = "any";
- public static final String GATE_IN_HOTBAR = "in_hotbar";
- public static final String GATE_NOT_IN_HOTBAR = "not_in_hotbar";
-
- /**
- * Primary-trigger modes for {@link ItemUse#triggerMode}: a bind fires from either its key
- * or a left/right-click while holding one of its {@link ItemUse#clickItems} — never both.
- * Environment {@link ItemUse#conditions} are a separate, additive axis on top of whichever is chosen.
- */
- public static final String TRIGGER_KEY = "key";
- public static final String TRIGGER_LEFT_CLICK = "left_click";
- public static final String TRIGGER_RIGHT_CLICK = "right_click";
-
- /**
- * The fixed set of environment-condition triggers, by id. Each is a boolean the mod evaluates each
- * tick; a bind with any of these listed fires when any of them flips from false to true
- * (edge-triggered, so it fires once per onset, not every tick while true). Thresholds are pre-set.
- */
- public static final List CONDITIONS = List.of(
- "low_health", "on_fire", "in_lava", "falling", "in_water", "submerged", "hurt", "sneaking", "sprinting");
-
- /**
- * One user-defined bind. At its core it names an item to right-click ({@link #item}) and a tap/hold
- * style ({@link #hold}). Its primary trigger is either a key or a left/right-click
- * while holding a chosen item set — selected by {@link #triggerMode} (with {@link #key} used only in
- * key mode and {@link #clickItems} only in the click modes). Pre-set environment {@link #conditions}
- * are an additional, additive trigger on top. {@link #hotbarGate} restricts firing by where the item
- * is, and {@link #vanillaSwap} plus the tick delays make an inventory-buried use a visible swap.
- */
- public static class ItemUse {
- /** Registry id of the item to use, e.g. {@code minecraft:wind_charge} or {@code minecraft:white_wool}. */
- public String item = "minecraft:wind_charge";
- /**
- * {@code false}: a single right-click (throwables, eggs, splash potions, placing a block,
- * firing an already-loaded crossbow). {@code true}: hold to use, so multi-tick actions can
- * finish — drawing a bow (fires on release), loading a crossbow, eating food, spyglass, etc.
- * Hold only applies to the key trigger; click/condition triggers always do a single use.
- */
- public boolean hold = false;
- /** GLFW key code that triggers this bind (key mode only), or {@link #UNBOUND}. */
- public int key = UNBOUND;
-
- // --- Primary trigger: either the key above, or a click ----------------------------------
- /** One of {@link #TRIGGER_KEY}/{@link #TRIGGER_LEFT_CLICK}/{@link #TRIGGER_RIGHT_CLICK}. */
- public String triggerMode = TRIGGER_KEY;
- /** Item ids that must be held for a click-mode trigger to fire; empty means any held item qualifies. */
- public List clickItems = new ArrayList<>();
- /** Environment-condition ids (subset of {@link #CONDITIONS}); also fires on the onset of any of them. */
- public List conditions = new ArrayList<>();
-
- // --- Gating & vanilla-swap behavior -----------------------------------------------------
- /** One of {@link #GATE_ANY}/{@link #GATE_IN_HOTBAR}/{@link #GATE_NOT_IN_HOTBAR}. */
- public String hotbarGate = GATE_ANY;
- /**
- * When the item is buried in the main inventory, do a visible legit-looking swap (bring it to
- * the held slot, right-click, swap it back) instead of the instant silent container swap.
- */
- public boolean vanillaSwap = false;
- /** Ticks to wait before the swap-to, before the use, and before swapping back (vanilla-swap only). */
- public int swapToDelay = 2;
- public int useDelay = 3;
- public int swapBackDelay = 2;
-
- public ItemUse() {
- }
-
- public ItemUse(String item, boolean hold, int key) {
- this.item = item;
- this.hold = hold;
- this.key = key;
- }
- }
-
- // --- User-defined use-item binds -----------------------------------------------------------
- public List itemUses = defaultItemUses();
-
- // --- Combat helpers: each active whenever its key is bound ----------------------------------
- /** Key that toggles the offhand between a shield and a totem of undying. */
- public int shieldTotemKey = GLFW.GLFW_KEY_H;
- /** Key that runs the two-tick axe-then-mace stun slam. */
- public int maceStunSlamKey = GLFW.GLFW_KEY_V;
-
- /** After a mace stun-slam, switch back to the hotbar slot that was selected before it started. */
- public boolean returnToStartSlotAfterSlam = false;
-
- /** Vanilla mode: perform the swap the slower, visible (tick-spaced) way instead of instantly. */
- public boolean shieldTotemVanilla = false;
- public boolean maceStunSlamVanilla = false;
-
- // --- Lunge swap combat helper --------------------------------------------------------------
- /**
- * Swaps to a spear (picking the one with the highest {@link #lungeEnchant} level), left-clicks with
- * it, then returns to the original slot. Fires from any of its three triggers — a key and/or a
- * left/right-click — while the main hand passes {@link #lungeSwapHeldItems}. All default off/unbound.
- */
- public int lungeSwapKey = UNBOUND;
- public boolean lungeSwapLeftClick = false;
- public boolean lungeSwapRightClick = false;
- /** Main-hand items that allow a lunge trigger to fire; empty = any. Use {@code minecraft:air} for an empty hand. */
- public List lungeSwapHeldItems = new ArrayList<>();
- /** Spear item ids to consider; among those carried, the highest-lunge one is used. Empty = any lunge-enchanted item. */
- public List spearItems = defaultSpears();
- /** Enchantment id used to rank spears by level. */
- public String lungeEnchant = "minecraft:lunge";
- /** Vanilla mode: visibly switch to the spear over a few ticks, attack, then switch back. */
- public boolean lungeSwapVanilla = false;
-
- // --- Shared combat-helper swap timing (used by whichever helper has vanilla mode on) --------
- public int combatSwapToDelay = 2;
- public int combatUseDelay = 3;
- public int combatSwapBackDelay = 2;
-
- private static List defaultSpears() {
- List list = new ArrayList<>();
- list.add("minecraft:wooden_spear");
- list.add("minecraft:stone_spear");
- list.add("minecraft:iron_spear");
- list.add("minecraft:golden_spear");
- list.add("minecraft:diamond_spear");
- list.add("minecraft:netherite_spear");
- return list;
- }
-
- private static List defaultItemUses() {
- // Mirrors the historical defaults: #1 throws a wind charge on R, #2 an ender pearl on G.
- List list = new ArrayList<>();
- list.add(new ItemUse("minecraft:wind_charge", false, GLFW.GLFW_KEY_R));
- list.add(new ItemUse("minecraft:ender_pearl", false, GLFW.GLFW_KEY_G));
- return list;
- }
-
- /** Whether a stored key code actually names a key (i.e. is not {@link #UNBOUND}). */
- public static boolean isBound(int key) {
- return key != UNBOUND;
- }
-
- /** Resolve an item id string to its {@link Item}, or {@code null} if it is blank or unknown. */
- public static Item resolveItem(String id) {
- if (id == null || id.isBlank()) {
- return null;
- }
- Identifier rl = Identifier.tryParse(id.trim());
- if (rl == null) {
- return null;
- }
- return BuiltInRegistries.ITEM.getOptional(rl).orElse(null);
- }
-
- // --- Persistence ---------------------------------------------------------------------------
-
- private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
- private static KeybindsConfig instance;
-
- private static Path path() {
- return FabricLoader.getInstance().getConfigDir().resolve(Keybinds.MOD_ID + ".json");
- }
-
- /** The live, shared config instance, loaded from disk on first access. */
- public static KeybindsConfig get() {
- if (instance == null) {
- instance = load();
- }
- return instance;
- }
-
- private static KeybindsConfig load() {
- Path file = path();
- if (Files.exists(file)) {
- try (Reader reader = Files.newBufferedReader(file)) {
- KeybindsConfig loaded = GSON.fromJson(reader, KeybindsConfig.class);
- if (loaded != null) {
- loaded.normalize();
- return loaded;
- }
- } catch (IOException | RuntimeException e) {
- Keybinds.LOGGER.warn("Could not read {}, using defaults", file, e);
- }
- }
- KeybindsConfig fresh = new KeybindsConfig();
- fresh.save();
- return fresh;
- }
-
- /** Fix up anything a hand-edited or older file might be missing after deserialization. */
- private void normalize() {
- if (itemUses == null) {
- itemUses = defaultItemUses();
- }
- itemUses.removeIf(u -> u == null);
- if (itemUses.size() > MAX_ITEM_USES) {
- Keybinds.LOGGER.warn("Trimming use-item binds from {} to the {}-slot cap", itemUses.size(), MAX_ITEM_USES);
- itemUses.subList(MAX_ITEM_USES, itemUses.size()).clear();
- }
- for (ItemUse u : itemUses) {
- if (u.item == null) {
- u.item = "";
- }
- if (u.clickItems == null) {
- u.clickItems = new ArrayList<>();
- }
- u.clickItems.removeIf(s -> s == null || s.isBlank());
- if (u.conditions == null) {
- u.conditions = new ArrayList<>();
- }
- u.conditions.removeIf(c -> c == null || !CONDITIONS.contains(c));
- if (u.hotbarGate == null
- || !(u.hotbarGate.equals(GATE_ANY) || u.hotbarGate.equals(GATE_IN_HOTBAR) || u.hotbarGate.equals(GATE_NOT_IN_HOTBAR))) {
- u.hotbarGate = GATE_ANY;
- }
- if (u.triggerMode == null
- || !(u.triggerMode.equals(TRIGGER_KEY) || u.triggerMode.equals(TRIGGER_LEFT_CLICK) || u.triggerMode.equals(TRIGGER_RIGHT_CLICK))) {
- u.triggerMode = TRIGGER_KEY;
- }
- u.swapToDelay = clampDelay(u.swapToDelay);
- u.useDelay = clampDelay(u.useDelay);
- u.swapBackDelay = clampDelay(u.swapBackDelay);
- }
-
- // Lunge swap + shared combat-helper fields.
- if (lungeSwapHeldItems == null) {
- lungeSwapHeldItems = new ArrayList<>();
- }
- lungeSwapHeldItems.removeIf(s -> s == null || s.isBlank());
- if (spearItems == null) {
- spearItems = defaultSpears();
- }
- spearItems.removeIf(s -> s == null || s.isBlank());
- if (lungeEnchant == null || lungeEnchant.isBlank()) {
- lungeEnchant = "minecraft:lunge";
- }
- combatSwapToDelay = clampDelay(combatSwapToDelay);
- combatUseDelay = clampDelay(combatUseDelay);
- combatSwapBackDelay = clampDelay(combatSwapBackDelay);
- }
-
- /** Keep tick delays within a sane, bounded range. */
- private static int clampDelay(int ticks) {
- return Math.max(0, Math.min(ticks, 40));
- }
-
- /** Write the current values back to {@code config/keybinds.json}. */
- public void save() {
- Path file = path();
- try {
- Files.createDirectories(file.getParent());
- try (Writer writer = Files.newBufferedWriter(file)) {
- GSON.toJson(this, writer);
- }
- } catch (IOException e) {
- Keybinds.LOGGER.warn("Could not write {}", file, e);
- }
- }
-}
diff --git a/src/main/java/com/example/KeybindsConfigScreen.java b/src/main/java/com/example/KeybindsConfigScreen.java
deleted file mode 100644
index bb6dc5b..0000000
--- a/src/main/java/com/example/KeybindsConfigScreen.java
+++ /dev/null
@@ -1,336 +0,0 @@
-package com.example;
-
-import com.mojang.blaze3d.platform.InputConstants;
-import net.minecraft.client.gui.components.Button;
-import net.minecraft.client.gui.components.CycleButton;
-import net.minecraft.client.gui.components.EditBox;
-import net.minecraft.client.gui.components.StringWidget;
-import net.minecraft.client.gui.screens.Screen;
-import net.minecraft.core.registries.BuiltInRegistries;
-import net.minecraft.network.chat.Component;
-import net.minecraft.resources.Identifier;
-import org.lwjgl.glfw.GLFW;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.IntConsumer;
-
-/**
- * The mod's settings screen, opened from Mod Menu's config button. This is the single place all keys
- * are assigned — the mod registers no vanilla key mappings. Each Use Item row is an item id,
- * a bind-key button, a tap/hold toggle, and a remove button, with add/remove and paging. Below the
- * list, the two combat helpers each get their own bind-key button plus the slam's return-slot toggle.
- *
- * Clicking a bind button starts listening: the next key press is captured for that bind, and
- * pressing Escape clears it (unbinds). Because keys are polled from config rather than
- * registered at launch, everything here takes effect immediately — no game restart. "Done" saves to
- * {@code config/keybinds.json}. Deliberately dependency-free: plain vanilla widgets only.
- */
-public class KeybindsConfigScreen extends Screen {
-
- private static final int COLUMN_WIDTH = 320;
- private static final int ROW_HEIGHT = 24;
- private static final int ROWS_PER_PAGE = 5;
-
- private static final int EDIT_WIDTH = 160;
- private static final int KEY_WIDTH = 92;
- private static final int VANILLA_WIDTH = 92;
- private static final int GEAR_WIDTH = 20;
- private static final int REMOVE_WIDTH = 20;
- private static final int GAP = 4;
-
- private final Screen parent;
- private final KeybindsConfig config;
-
- private int page = 0;
-
- /** When non-null, the screen is listening for a key press to feed to this setter. */
- private IntConsumer keyCapture;
-
- /** Every item id (and, for vanilla items, its bare path) — the autocomplete source, built once. */
- private static List completions;
-
- public KeybindsConfigScreen(Screen parent) {
- super(Component.translatable("keybinds.config.title"));
- this.parent = parent;
- this.config = KeybindsConfig.get();
- }
-
- @Override
- protected void init() {
- int left = this.width / 2 - COLUMN_WIDTH / 2;
- int y = 24;
-
- addRenderableWidget(new StringWidget(0, 8, this.width, 20, this.title, this.font));
-
- // --- Use Item binds -------------------------------------------------------------------
- addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12,
- Component.translatable("keybinds.config.uses_header"), this.font));
- y += 14;
- addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12,
- Component.translatable("keybinds.config.uses_note"), this.font));
- y += 16;
-
- int totalPages = Math.max(1, (config.itemUses.size() + ROWS_PER_PAGE - 1) / ROWS_PER_PAGE);
- page = Math.max(0, Math.min(page, totalPages - 1));
-
- int first = page * ROWS_PER_PAGE;
- int last = Math.min(first + ROWS_PER_PAGE, config.itemUses.size());
- for (int i = first; i < last; i++) {
- addUseRow(left, y, i);
- y += ROW_HEIGHT;
- }
-
- // --- Add / paging row -----------------------------------------------------------------
- boolean canAdd = config.itemUses.size() < KeybindsConfig.MAX_ITEM_USES;
- Button add = Button.builder(Component.translatable("keybinds.config.add_slot"), b -> {
- config.itemUses.add(new KeybindsConfig.ItemUse("", false, KeybindsConfig.UNBOUND));
- page = (config.itemUses.size() - 1) / ROWS_PER_PAGE;
- rebuildWidgets();
- })
- .bounds(left, y, EDIT_WIDTH, 20)
- .build();
- add.active = canAdd;
- addRenderableWidget(add);
-
- Button prev = Button.builder(Component.literal("◄"), b -> {
- page--;
- rebuildWidgets();
- })
- .bounds(left + EDIT_WIDTH + GAP, y, 32, 20)
- .build();
- prev.active = page > 0;
- addRenderableWidget(prev);
-
- Button next = Button.builder(Component.literal("►"), b -> {
- page++;
- rebuildWidgets();
- })
- .bounds(left + EDIT_WIDTH + GAP + 36, y, 32, 20)
- .build();
- next.active = page < totalPages - 1;
- addRenderableWidget(next);
-
- y += ROW_HEIGHT + 4;
-
- // --- Combat helpers -------------------------------------------------------------------
- addRenderableWidget(new StringWidget(left, y, COLUMN_WIDTH, 12,
- Component.translatable("keybinds.config.helpers_header"), this.font));
- y += 16;
-
- // Each helper: a bind-key button plus a "Vanilla" toggle that makes its swap slow/visible.
- int keyCol = COLUMN_WIDTH - VANILLA_WIDTH - GAP;
- addRenderableWidget(labelledKeyButton(left, y, keyCol, "keybinds.config.shield_totem",
- config.shieldTotemKey, code -> config.shieldTotemKey = code));
- addRenderableWidget(vanillaCycle(left + keyCol + GAP, y, VANILLA_WIDTH,
- config.shieldTotemVanilla, v -> config.shieldTotemVanilla = v));
- y += ROW_HEIGHT;
- addRenderableWidget(labelledKeyButton(left, y, keyCol, "keybinds.config.mace_slam",
- config.maceStunSlamKey, code -> config.maceStunSlamKey = code));
- addRenderableWidget(vanillaCycle(left + keyCol + GAP, y, VANILLA_WIDTH,
- config.maceStunSlamVanilla, v -> config.maceStunSlamVanilla = v));
- y += ROW_HEIGHT;
-
- addRenderableWidget(CycleButton.onOffBuilder(config.returnToStartSlotAfterSlam)
- .create(left, y, COLUMN_WIDTH, 20, Component.translatable("keybinds.config.return_slot"),
- (button, value) -> config.returnToStartSlotAfterSlam = value));
- y += ROW_HEIGHT;
-
- // Lunge swap: key button + vanilla toggle + a ⚙ opening its own editor for the extra options.
- int lungeKeyCol = COLUMN_WIDTH - VANILLA_WIDTH - GEAR_WIDTH - 2 * GAP;
- addRenderableWidget(labelledKeyButton(left, y, lungeKeyCol, "keybinds.config.lunge_swap",
- config.lungeSwapKey, code -> config.lungeSwapKey = code));
- addRenderableWidget(vanillaCycle(left + lungeKeyCol + GAP, y, VANILLA_WIDTH,
- config.lungeSwapVanilla, v -> config.lungeSwapVanilla = v));
- Button lungeGear = Button.builder(Component.literal("⚙"), b -> openLungeEditor())
- .bounds(left + lungeKeyCol + GAP + VANILLA_WIDTH + GAP, y, GEAR_WIDTH, 20)
- .build();
- lungeGear.setTooltip(net.minecraft.client.gui.components.Tooltip.create(
- Component.translatable("keybinds.config.lunge_edit_tooltip")));
- addRenderableWidget(lungeGear);
-
- addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose())
- .bounds(this.width / 2 - 100, this.height - 28, 200, 20)
- .build());
- }
-
- /** A "Vanilla" on/off toggle with a shared help tooltip, for a combat helper's swap style. */
- private CycleButton vanillaCycle(int x, int y, int w, boolean initial,
- java.util.function.Consumer setter) {
- CycleButton button = CycleButton.onOffBuilder(initial)
- .create(x, y, w, 20, Component.translatable("keybinds.config.vanilla"),
- (b, v) -> setter.accept(v));
- button.setTooltip(net.minecraft.client.gui.components.Tooltip.create(
- Component.translatable("keybinds.config.vanilla_help")));
- return button;
- }
-
- /** Persist current values and open the lunge-swap advanced editor. */
- private void openLungeEditor() {
- config.save();
- if (this.minecraft != null) {
- this.minecraft.setScreenAndShow(new LungeSwapScreen(this, config));
- }
- }
-
- /** One editable use-item row: item-id box, bind-key button, tap/hold toggle, remove button. */
- private void addUseRow(int left, int y, int index) {
- KeybindsConfig.ItemUse use = config.itemUses.get(index);
- int x = left;
-
- EditBox box = new EditBox(this.font, x, y, EDIT_WIDTH, 20,
- Component.translatable("keybinds.config.item_id"));
- box.setMaxLength(128);
- box.setValue(use.item == null ? "" : use.item);
- box.setHint(Component.literal("#" + (index + 1) + " item"));
- box.setResponder(text -> {
- use.item = text;
- box.setTextColor(itemColor(text));
- box.setSuggestion(bestCompletionSuffix(text));
- });
- box.setTextColor(itemColor(use.item));
- box.setSuggestion(bestCompletionSuffix(box.getValue()));
- addRenderableWidget(box);
- x += EDIT_WIDTH + GAP;
-
- addRenderableWidget(triggerButton(x, y, KEY_WIDTH, index, use));
- x += KEY_WIDTH + GAP;
-
- Button gear = Button.builder(Component.literal("⚙"), b -> openEditor(index))
- .bounds(x, y, GEAR_WIDTH, 20)
- .build();
- gear.setTooltip(net.minecraft.client.gui.components.Tooltip.create(
- Component.translatable("keybinds.config.edit_tooltip")));
- addRenderableWidget(gear);
- x += GEAR_WIDTH + GAP;
-
- addRenderableWidget(Button.builder(Component.literal("✕"), b -> {
- config.itemUses.remove(index);
- rebuildWidgets();
- })
- .bounds(x, y, REMOVE_WIDTH, 20)
- .build());
- }
-
- /** A bare bind-key button showing just the current key's name. */
- private Button keyButton(int x, int y, int w, int currentKey, IntConsumer setter) {
- return Button.builder(keyLabel(currentKey), b -> beginCapture(setter))
- .bounds(x, y, w, 20)
- .build();
- }
-
- /**
- * The row's trigger control. In key mode it captures a key straight from the list. In a click mode
- * there is no key to set, so it shows the click label and opens the ⚙ editor (where the click's
- * held-item filter lives).
- */
- private Button triggerButton(int x, int y, int w, int index, KeybindsConfig.ItemUse use) {
- String mode = use.triggerMode == null ? KeybindsConfig.TRIGGER_KEY : use.triggerMode;
- if (mode.equals(KeybindsConfig.TRIGGER_KEY)) {
- return keyButton(x, y, w, use.key, code -> use.key = code);
- }
- Component label = Component.translatable("keybinds.trigger." + mode);
- return Button.builder(label, b -> openEditor(index))
- .bounds(x, y, w, 20)
- .build();
- }
-
- /** Persist the current values and open the per-bind advanced editor for {@code index}. */
- private void openEditor(int index) {
- config.save();
- if (this.minecraft != null) {
- this.minecraft.setScreenAndShow(new BindEditScreen(this, config, index));
- }
- }
-
- /** A full-width bind-key button prefixed with a translated label, for the combat helpers. */
- private Button labelledKeyButton(int x, int y, int w, String labelKey, int currentKey, IntConsumer setter) {
- Component label = Component.translatable(labelKey).copy()
- .append(Component.literal(": ")).append(keyLabel(currentKey));
- return Button.builder(label, b -> beginCapture(setter))
- .bounds(x, y, w, 20)
- .build();
- }
-
- /** Start listening for the next key press, dropping widget focus so it isn't typed into a box. */
- private void beginCapture(IntConsumer setter) {
- this.setFocused(null);
- this.keyCapture = setter;
- }
-
- private Component keyLabel(int key) {
- if (!KeybindsConfig.isBound(key)) {
- return Component.translatable("keybinds.config.unbound");
- }
- return InputConstants.Type.KEYSYM.getOrCreate(key).getDisplayName();
- }
-
- /** Opaque so the text is visible: EditBox treats the color as ARGB, and a 0x00 alpha renders invisibly. */
- private int itemColor(String text) {
- return (text == null || text.isBlank() || KeybindsConfig.resolveItem(text) != null) ? 0xFFFFFFFF : 0xFFFF5555;
- }
-
- /** Item ids plus, for vanilla items, their bare paths (a bare path resolves via the default namespace). */
- private static List completions() {
- if (completions == null) {
- List list = new ArrayList<>();
- for (Identifier id : BuiltInRegistries.ITEM.keySet()) {
- list.add(id.toString());
- if (id.getNamespace().equals("minecraft")) {
- list.add(id.getPath());
- }
- }
- list.sort(null);
- completions = list;
- }
- return completions;
- }
-
- /**
- * The remaining characters of the first item id that starts with {@code text}, for the EditBox's
- * ghost suggestion. Matching is case-sensitive (item ids are lowercase) so the completion always
- * appends to a valid prefix. Empty when nothing matches or the text is already a full id.
- */
- private static String bestCompletionSuffix(String text) {
- if (text == null || text.isEmpty()) {
- return "";
- }
- for (String c : completions()) {
- if (c.length() > text.length() && c.startsWith(text)) {
- return c.substring(text.length());
- }
- }
- return "";
- }
-
- @Override
- public boolean keyPressed(net.minecraft.client.input.KeyEvent event) {
- if (keyCapture != null) {
- // Escape clears the bind; any other key becomes the new binding.
- int assigned = event.key() == GLFW.GLFW_KEY_ESCAPE ? KeybindsConfig.UNBOUND : event.key();
- keyCapture.accept(assigned);
- keyCapture = null;
- rebuildWidgets();
- return true;
- }
- // Tab accepts the autocomplete suggestion in a focused item box.
- if (event.key() == GLFW.GLFW_KEY_TAB && getFocused() instanceof EditBox box) {
- String suffix = bestCompletionSuffix(box.getValue());
- if (!suffix.isEmpty()) {
- box.setValue(box.getValue() + suffix);
- return true;
- }
- }
- return super.keyPressed(event);
- }
-
- @Override
- public void onClose() {
- // Drop fully-blank rows so the file stays tidy, then persist.
- config.itemUses.removeIf(u -> u.item == null || u.item.isBlank());
- config.save();
- if (this.minecraft != null) {
- this.minecraft.setScreenAndShow(parent);
- }
- }
-}
diff --git a/src/main/java/com/example/LungeSwapScreen.java b/src/main/java/com/example/LungeSwapScreen.java
deleted file mode 100644
index 67ad205..0000000
--- a/src/main/java/com/example/LungeSwapScreen.java
+++ /dev/null
@@ -1,125 +0,0 @@
-package com.example;
-
-import net.minecraft.client.gui.components.AbstractWidget;
-import net.minecraft.client.gui.components.Button;
-import net.minecraft.client.gui.components.CycleButton;
-import net.minecraft.client.gui.components.EditBox;
-import net.minecraft.client.gui.components.StringWidget;
-import net.minecraft.client.gui.components.Tooltip;
-import net.minecraft.client.gui.screens.Screen;
-import net.minecraft.network.chat.Component;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.function.Consumer;
-
-/**
- * Advanced editor for the Lunge Swap combat helper, opened from the ⚙ next to its bind key on
- * {@link KeybindsConfigScreen}. The key and the "Vanilla" toggle live on that main row; everything else
- * lives here so the main list stays compact: the two extra click triggers, the held-item filter, the
- * spear item ids to swap to, and the enchantment id used to rank them by level. Each control carries a
- * small {@code ?} help button with a tooltip. Changes write straight through to the shared config;
- * {@code Done} persists and returns.
- */
-public class LungeSwapScreen extends Screen {
-
- private static final int COLUMN_WIDTH = 320;
- private static final int ROW = 24;
- private static final int GAP = 4;
- private static final int HELP = 20;
-
- private final Screen parent;
- private final KeybindsConfig config;
-
- private int left;
-
- public LungeSwapScreen(Screen parent, KeybindsConfig config) {
- super(Component.translatable("keybinds.lunge.title"));
- this.parent = parent;
- this.config = config;
- }
-
- @Override
- protected void init() {
- left = this.width / 2 - COLUMN_WIDTH / 2;
- int contentWidth = COLUMN_WIDTH - HELP - GAP;
- int y = 28;
-
- addRenderableWidget(new StringWidget(0, 8, this.width, 16, this.title, this.font));
-
- // --- Extra click triggers -------------------------------------------------------------
- row(y, CycleButton.onOffBuilder(config.lungeSwapLeftClick)
- .create(left, y, contentWidth, 20, Component.translatable("keybinds.lunge.left_click"),
- (b, v) -> config.lungeSwapLeftClick = v), "keybinds.lunge.left_click_help");
- y += ROW;
-
- row(y, CycleButton.onOffBuilder(config.lungeSwapRightClick)
- .create(left, y, contentWidth, 20, Component.translatable("keybinds.lunge.right_click"),
- (b, v) -> config.lungeSwapRightClick = v), "keybinds.lunge.right_click_help");
- y += ROW;
-
- // --- Held-item filter -----------------------------------------------------------------
- EditBox held = listBox(y, "keybinds.lunge.held", "keybinds.lunge.held_hint",
- config.lungeSwapHeldItems, ids -> config.lungeSwapHeldItems = ids);
- row(y, held, "keybinds.lunge.held_help");
- y += ROW;
-
- // --- Spear ids ------------------------------------------------------------------------
- EditBox spears = listBox(y, "keybinds.lunge.spears", "keybinds.lunge.spears_hint",
- config.spearItems, ids -> config.spearItems = ids);
- row(y, spears, "keybinds.lunge.spears_help");
- y += ROW;
-
- // --- Lunge enchantment id -------------------------------------------------------------
- EditBox enchant = new EditBox(this.font, left, y, contentWidth, 20,
- Component.translatable("keybinds.lunge.enchant"));
- enchant.setMaxLength(128);
- enchant.setValue(config.lungeEnchant == null ? "" : config.lungeEnchant);
- enchant.setResponder(t -> config.lungeEnchant = t);
- row(y, enchant, "keybinds.lunge.enchant_help");
- y += ROW;
-
- addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose())
- .bounds(this.width / 2 - 100, this.height - 28, 200, 20)
- .build());
- }
-
- /** A comma-separated item-id list box that writes back a trimmed, blank-free list of ids. */
- private EditBox listBox(int y, String labelKey, String hintKey, List current,
- Consumer> setter) {
- EditBox box = new EditBox(this.font, left, y, COLUMN_WIDTH - HELP - GAP, 20,
- Component.translatable(labelKey));
- box.setMaxLength(512);
- box.setValue(String.join(", ", current));
- box.setHint(Component.translatable(hintKey));
- box.setResponder(t -> {
- List ids = new ArrayList<>();
- for (String part : t.split(",")) {
- String id = part.trim();
- if (!id.isBlank()) {
- ids.add(id);
- }
- }
- setter.accept(ids);
- });
- return box;
- }
-
- /** Add a control plus a trailing {@code ?} help button sharing its row. */
- private void row(int y, AbstractWidget control, String helpKey) {
- addRenderableWidget(control);
- Button help = Button.builder(Component.literal("?"), b -> {})
- .bounds(left + COLUMN_WIDTH - HELP, y, HELP, 20)
- .build();
- help.setTooltip(Tooltip.create(Component.translatable(helpKey)));
- addRenderableWidget(help);
- }
-
- @Override
- public void onClose() {
- config.save();
- if (this.minecraft != null) {
- this.minecraft.setScreenAndShow(parent);
- }
- }
-}
diff --git a/src/main/java/com/example/ModMenuIntegration.java b/src/main/java/com/example/ModMenuIntegration.java
deleted file mode 100644
index fcd9d48..0000000
--- a/src/main/java/com/example/ModMenuIntegration.java
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.example;
-
-import com.terraformersmc.modmenu.api.ConfigScreenFactory;
-import com.terraformersmc.modmenu.api.ModMenuApi;
-
-/**
- * Mod Menu entrypoint. Wires the "config" button next to this mod in the Mods list to our
- * {@link KeybindsConfigScreen}. This class is only loaded when Mod Menu is present, so Mod Menu is
- * an optional dependency — the mod runs fine without it.
- */
-public class ModMenuIntegration implements ModMenuApi {
-
- @Override
- public ConfigScreenFactory> getModConfigScreenFactory() {
- return KeybindsConfigScreen::new;
- }
-}
diff --git a/src/main/java/com/kingjayan/keybinds/Keybinds.java b/src/main/java/com/kingjayan/keybinds/Keybinds.java
new file mode 100644
index 0000000..1d019d8
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/Keybinds.java
@@ -0,0 +1,22 @@
+package com.kingjayan.keybinds;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import net.fabricmc.api.ModInitializer;
+import net.minecraft.resources.Identifier;
+
+public class Keybinds implements ModInitializer {
+ public static final String MOD_ID = "keybinds";
+
+ public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID);
+
+ @Override
+ public void onInitialize() {
+ LOGGER.info("keybinds initialized");
+ }
+
+ public static Identifier id(String path) {
+ return Identifier.fromNamespaceAndPath(MOD_ID, path);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/KeybindsClient.java b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java
new file mode 100644
index 0000000..953e3f2
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/KeybindsClient.java
@@ -0,0 +1,88 @@
+package com.kingjayan.keybinds;
+
+import com.kingjayan.keybinds.core.action.ActionTypes;
+import com.kingjayan.keybinds.core.exec.ExecutionEngine;
+import com.kingjayan.keybinds.core.input.EdgeDetector;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.macro.MacroRegistry;
+import com.kingjayan.keybinds.core.schedule.TickScheduler;
+import com.kingjayan.keybinds.core.trigger.TriggerContext;
+import com.kingjayan.keybinds.core.trigger.TriggerIndex;
+import com.kingjayan.keybinds.core.trigger.TriggerTypes;
+import com.kingjayan.keybinds.dsl.ScriptRuntime;
+import com.kingjayan.keybinds.persist.MacroStorage;
+import com.kingjayan.keybinds.ui.editor.MacroListScreen;
+import com.mojang.blaze3d.platform.InputConstants;
+import net.fabricmc.api.ClientModInitializer;
+import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
+import net.minecraft.client.Minecraft;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.List;
+
+public class KeybindsClient implements ClientModInitializer {
+
+ private final TickScheduler scheduler = new TickScheduler();
+ private final ExecutionEngine engine = new ExecutionEngine();
+ private final TriggerContext triggers = new TriggerContext();
+ private final EdgeDetector screenKeyEdges = new EdgeDetector();
+ private final TriggerIndex triggerIndex = new TriggerIndex();
+ private final ScriptRuntime scripts = new ScriptRuntime();
+
+ private int indexedRevision = -1;
+
+ @Override
+ public void onInitializeClient() {
+ ActionTypes.bootstrap();
+ TriggerTypes.bootstrap();
+ MacroRegistry.get().replaceAll(MacroStorage.load());
+
+ ClientTickEvents.END_CLIENT_TICK.register(client -> {
+ scheduler.tick();
+ engine.tick();
+ scripts.tick();
+
+ if (client.player == null || client.level == null || !client.mouseHandler.isMouseGrabbed()) {
+ triggers.reset();
+ screenKeyEdges.reset();
+ return;
+ }
+
+ int revision = MacroRegistry.get().revision();
+ if (revision != indexedRevision) {
+ scripts.recompile(MacroRegistry.get().all());
+ triggerIndex.rebuild(MacroRegistry.get().all());
+ indexedRevision = revision;
+ }
+
+ if (screenKeyEdges.justPressed(GLFW.GLFW_KEY_RIGHT_SHIFT,
+ InputConstants.isKeyDown(client.getWindow(), GLFW.GLFW_KEY_RIGHT_SHIFT))
+ && !screenKeyEdges.resyncing()) {
+ client.setScreenAndShow(new MacroListScreen(null));
+ triggers.reset();
+ screenKeyEdges.reset();
+ return;
+ }
+
+ triggers.begin(client);
+ fireMacros(client, triggerIndex.candidates(triggers));
+ triggers.commit();
+ screenKeyEdges.commit();
+ });
+ }
+
+ private void fireMacros(Minecraft client, List macros) {
+ for (Macro macro : macros) {
+ if (!macro.enabled() || macro.trigger() == null) {
+ continue;
+ }
+ if (macro.trigger().evaluate(triggers).fired()) {
+ if (macro.script() != null) {
+ scripts.start(macro, client, scheduler);
+ } else {
+ engine.start(macro, client, scheduler);
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java b/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java
new file mode 100644
index 0000000..3c786d2
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ModMenuIntegration.java
@@ -0,0 +1,18 @@
+package com.kingjayan.keybinds;
+
+import com.kingjayan.keybinds.ui.editor.MacroListScreen;
+import com.terraformersmc.modmenu.api.ConfigScreenFactory;
+import com.terraformersmc.modmenu.api.ModMenuApi;
+
+/**
+ * Mod Menu entrypoint. Wires the "config" button next to this mod in the Mods list to the
+ * {@link MacroListScreen} — the same screen Right Shift opens in-game. This class is only loaded
+ * when Mod Menu is present, so Mod Menu is an optional dependency — the mod runs fine without it.
+ */
+public class ModMenuIntegration implements ModMenuApi {
+
+ @Override
+ public ConfigScreenFactory> getModConfigScreenFactory() {
+ return MacroListScreen::new;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/Action.java b/src/main/java/com/kingjayan/keybinds/core/action/Action.java
new file mode 100644
index 0000000..6297ce2
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/Action.java
@@ -0,0 +1,12 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+
+public interface Action {
+
+ ActionType type();
+
+ void write(JsonObject out);
+
+ ActionResult execute(ActionContext ctx);
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/ActionContext.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionContext.java
new file mode 100644
index 0000000..8b9b82d
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionContext.java
@@ -0,0 +1,41 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.core.schedule.TickScheduler;
+import net.minecraft.client.Minecraft;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+public final class ActionContext {
+
+ private final Minecraft client;
+ private final TickScheduler scheduler;
+ private final int startSlot;
+ private final Deque swapBacks = new ArrayDeque<>();
+
+ public ActionContext(Minecraft client, TickScheduler scheduler, int startSlot) {
+ this.client = client;
+ this.scheduler = scheduler;
+ this.startSlot = startSlot;
+ }
+
+ public Minecraft client() {
+ return client;
+ }
+
+ public TickScheduler scheduler() {
+ return scheduler;
+ }
+
+ public int startSlot() {
+ return startSlot;
+ }
+
+ public void pushSwapBack(Runnable undo) {
+ swapBacks.push(undo);
+ }
+
+ public Runnable popSwapBack() {
+ return swapBacks.poll();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/ActionRegistry.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionRegistry.java
new file mode 100644
index 0000000..a068b33
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionRegistry.java
@@ -0,0 +1,49 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+import net.minecraft.util.GsonHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class ActionRegistry {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger("keybinds");
+ private static final Map TYPES = new LinkedHashMap<>();
+
+ private ActionRegistry() {
+ }
+
+ public static ActionType register(ActionType type) {
+ TYPES.put(type.id(), type);
+ return type;
+ }
+
+ public static ActionType byId(String id) {
+ return TYPES.get(id);
+ }
+
+ public static List ids() {
+ return List.copyOf(TYPES.keySet());
+ }
+
+ public static JsonObject toJson(Action action) {
+ JsonObject out = new JsonObject();
+ out.addProperty("type", action.type().id());
+ action.write(out);
+ return out;
+ }
+
+ public static Action fromJson(JsonObject json) {
+ String id = GsonHelper.getAsString(json, "type", "");
+ ActionType type = TYPES.get(id);
+ if (type == null) {
+ LOGGER.warn("Unknown action type '{}', skipping", id);
+ return null;
+ }
+ return type.reader().apply(json);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/ActionResult.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionResult.java
new file mode 100644
index 0000000..1ed5203
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionResult.java
@@ -0,0 +1,35 @@
+package com.kingjayan.keybinds.core.action;
+
+public final class ActionResult {
+
+ private static final ActionResult PROCEED = new ActionResult(false, 0);
+ private static final ActionResult ABORT = new ActionResult(true, 0);
+
+ private final boolean aborted;
+ private final int delayTicks;
+
+ private ActionResult(boolean aborted, int delayTicks) {
+ this.aborted = aborted;
+ this.delayTicks = delayTicks;
+ }
+
+ public static ActionResult proceed() {
+ return PROCEED;
+ }
+
+ public static ActionResult abort() {
+ return ABORT;
+ }
+
+ public static ActionResult delay(int ticks) {
+ return ticks <= 0 ? PROCEED : new ActionResult(false, ticks);
+ }
+
+ public boolean aborted() {
+ return aborted;
+ }
+
+ public int delayTicks() {
+ return delayTicks;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/ActionType.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionType.java
new file mode 100644
index 0000000..e71e7d8
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionType.java
@@ -0,0 +1,8 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+
+import java.util.function.Function;
+
+public record ActionType(String id, Function reader) {
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java b/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java
new file mode 100644
index 0000000..4053b3d
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/ActionTypes.java
@@ -0,0 +1,95 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import net.minecraft.util.GsonHelper;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class ActionTypes {
+
+ public static final ActionType WAIT = ActionRegistry.register(new ActionType("wait",
+ json -> new WaitAction(GsonHelper.getAsInt(json, "ticks", 1))));
+
+ public static final ActionType SELECT_SLOT = ActionRegistry.register(new ActionType("select_slot",
+ json -> new SelectSlotAction(GsonHelper.getAsInt(json, "slot", 0))));
+
+ public static final ActionType RESTORE_SLOT = ActionRegistry.register(new ActionType("restore_slot",
+ json -> new RestoreSlotAction()));
+
+ public static final ActionType SELECT_ITEM = ActionRegistry.register(new ActionType("select_item",
+ json -> new SelectItemAction(readStrings(json, "items"), GsonHelper.getAsString(json, "tag", ""))));
+
+ public static final ActionType REQUIRE_ITEM = ActionRegistry.register(new ActionType("require_item",
+ json -> new RequireItemAction(readStrings(json, "items"), GsonHelper.getAsString(json, "tag", ""))));
+
+ public static final ActionType SWAP_TO_ITEM = ActionRegistry.register(new ActionType("swap_to_item",
+ json -> new SwapToItemAction(readStrings(json, "items"), GsonHelper.getAsString(json, "rankEnchant", ""))));
+
+ public static final ActionType SWAP_BACK = ActionRegistry.register(new ActionType("swap_back",
+ json -> new SwapBackAction()));
+
+ public static final ActionType ATTACK = ActionRegistry.register(new ActionType("attack",
+ json -> new AttackAction(GsonHelper.getAsBoolean(json, "swing", false))));
+
+ public static final ActionType USE = ActionRegistry.register(new ActionType("use",
+ json -> new UseAction()));
+
+ public static final ActionType USE_ITEM = ActionRegistry.register(new ActionType("use_item",
+ json -> new UseItemAction(
+ GsonHelper.getAsString(json, "item", ""),
+ GsonHelper.getAsString(json, "hotbarGate", UseItemAction.GATE_ANY),
+ GsonHelper.getAsBoolean(json, "vanillaSwap", false),
+ GsonHelper.getAsInt(json, "swapToDelay", 2),
+ GsonHelper.getAsInt(json, "useDelay", 3),
+ GsonHelper.getAsInt(json, "swapBackDelay", 2))));
+
+ public static final ActionType SHIELD_TOTEM_SWAP = ActionRegistry.register(new ActionType("shield_totem_swap",
+ json -> new ShieldTotemSwapAction()));
+
+ public static final ActionType LOOK = ActionRegistry.register(new ActionType("look",
+ json -> new LookAction(GsonHelper.getAsString(json, "direction", "north"))));
+
+ public static final ActionType TURN = ActionRegistry.register(new ActionType("turn",
+ json -> new TurnAction(GsonHelper.getAsFloat(json, "degrees", 0.0F))));
+
+ public static final ActionType SPRINT = ActionRegistry.register(new ActionType("sprint",
+ json -> new SprintAction(GsonHelper.getAsString(json, "mode", "on"))));
+
+ public static final ActionType SNEAK = ActionRegistry.register(new ActionType("sneak",
+ json -> new SneakAction(GsonHelper.getAsString(json, "mode", "on"))));
+
+ public static final ActionType JUMP = ActionRegistry.register(new ActionType("jump",
+ json -> new JumpAction()));
+
+ public static final ActionType DOUBLE_JUMP = ActionRegistry.register(new ActionType("double_jump",
+ json -> new DoubleJumpAction()));
+
+ public static final ActionType MOVE = ActionRegistry.register(new ActionType("move",
+ json -> new MoveAction(
+ GsonHelper.getAsString(json, "direction", "forward"),
+ GsonHelper.getAsFloat(json, "blocks", 1.0F))));
+
+ public static final ActionType RANDOM_WAIT = ActionRegistry.register(new ActionType("random_wait",
+ json -> new RandomWaitAction(
+ GsonHelper.getAsInt(json, "min", 1),
+ GsonHelper.getAsInt(json, "max", 10))));
+
+ private ActionTypes() {
+ }
+
+ public static void bootstrap() {
+ }
+
+ private static List readStrings(JsonObject json, String field) {
+ List values = new ArrayList<>();
+ for (JsonElement element : GsonHelper.getAsJsonArray(json, field, new JsonArray())) {
+ if (element.isJsonPrimitive()) {
+ values.add(element.getAsString());
+ }
+ }
+ return values;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/AttackAction.java b/src/main/java/com/kingjayan/keybinds/core/action/AttackAction.java
new file mode 100644
index 0000000..04f7b37
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/AttackAction.java
@@ -0,0 +1,27 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.game.PlayerActions;
+import com.google.gson.JsonObject;
+
+public record AttackAction(boolean swing) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.ATTACK;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("swing", swing);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ if (swing) {
+ PlayerActions.attackCrosshairOrSwing(ctx.client());
+ } else {
+ PlayerActions.attackCrosshairTarget(ctx.client());
+ }
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/DoubleJumpAction.java b/src/main/java/com/kingjayan/keybinds/core/action/DoubleJumpAction.java
new file mode 100644
index 0000000..af1878e
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/DoubleJumpAction.java
@@ -0,0 +1,29 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+import net.minecraft.client.KeyMapping;
+
+public record DoubleJumpAction() implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.DOUBLE_JUMP;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ if (ctx.client() == null || ctx.client().player == null) {
+ return ActionResult.proceed();
+ }
+ KeyMapping jump = ctx.client().options.keyJump;
+ jump.setDown(true);
+ ctx.scheduler().schedule(1, () -> jump.setDown(false));
+ ctx.scheduler().schedule(4, () -> jump.setDown(true));
+ ctx.scheduler().schedule(5, () -> jump.setDown(false));
+ return ActionResult.delay(5);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/JumpAction.java b/src/main/java/com/kingjayan/keybinds/core/action/JumpAction.java
new file mode 100644
index 0000000..36c7f81
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/JumpAction.java
@@ -0,0 +1,27 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+import net.minecraft.client.KeyMapping;
+
+public record JumpAction() implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.JUMP;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ if (ctx.client() == null || ctx.client().player == null) {
+ return ActionResult.proceed();
+ }
+ KeyMapping jump = ctx.client().options.keyJump;
+ jump.setDown(true);
+ ctx.scheduler().schedule(1, () -> jump.setDown(false));
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/LookAction.java b/src/main/java/com/kingjayan/keybinds/core/action/LookAction.java
new file mode 100644
index 0000000..afaf220
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/LookAction.java
@@ -0,0 +1,39 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+import net.minecraft.client.player.LocalPlayer;
+import net.minecraft.util.Mth;
+
+public record LookAction(String direction) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.LOOK;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("direction", direction);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ LocalPlayer player = ctx.client() != null ? ctx.client().player : null;
+ if (player == null) {
+ return ActionResult.proceed();
+ }
+ switch (direction) {
+ case "north" -> player.setYRot(180.0F);
+ case "south" -> player.setYRot(0.0F);
+ case "east" -> player.setYRot(-90.0F);
+ case "west" -> player.setYRot(90.0F);
+ case "up" -> player.setXRot(-90.0F);
+ case "down" -> player.setXRot(90.0F);
+ case "left" -> player.setYRot(Mth.wrapDegrees(player.getYRot() - 90.0F));
+ case "right" -> player.setYRot(Mth.wrapDegrees(player.getYRot() + 90.0F));
+ default -> {
+ }
+ }
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/MoveAction.java b/src/main/java/com/kingjayan/keybinds/core/action/MoveAction.java
new file mode 100644
index 0000000..cfbd89d
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/MoveAction.java
@@ -0,0 +1,40 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+import net.minecraft.client.KeyMapping;
+import net.minecraft.client.player.LocalPlayer;
+
+public record MoveAction(String direction, float blocks) implements Action {
+
+ private static final float BLOCKS_PER_TICK_PER_SPEED = 2.158F;
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.MOVE;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("direction", direction);
+ out.addProperty("blocks", blocks);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ LocalPlayer player = ctx.client() != null ? ctx.client().player : null;
+ if (player == null || blocks <= 0.0F) {
+ return ActionResult.proceed();
+ }
+ KeyMapping key = switch (direction) {
+ case "back" -> ctx.client().options.keyDown;
+ case "left" -> ctx.client().options.keyLeft;
+ case "right" -> ctx.client().options.keyRight;
+ default -> ctx.client().options.keyUp;
+ };
+ float speed = player.getSpeed() > 0.0F ? player.getSpeed() : 0.1F;
+ int ticks = Math.max(1, Math.round(blocks / (speed * BLOCKS_PER_TICK_PER_SPEED)));
+ key.setDown(true);
+ ctx.scheduler().schedule(ticks, () -> key.setDown(false));
+ return ActionResult.delay(ticks);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/RandomWaitAction.java b/src/main/java/com/kingjayan/keybinds/core/action/RandomWaitAction.java
new file mode 100644
index 0000000..eed9bbd
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/RandomWaitAction.java
@@ -0,0 +1,26 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+public record RandomWaitAction(int min, int max) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.RANDOM_WAIT;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("min", min);
+ out.addProperty("max", max);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ int low = Math.max(0, Math.min(min, max));
+ int high = Math.max(0, Math.max(min, max));
+ return ActionResult.delay(ThreadLocalRandom.current().nextInt(low, high + 1));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/RequireItemAction.java b/src/main/java/com/kingjayan/keybinds/core/action/RequireItemAction.java
new file mode 100644
index 0000000..cdf8d86
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/RequireItemAction.java
@@ -0,0 +1,40 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.game.ItemLookup;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import net.minecraft.client.player.LocalPlayer;
+
+import java.util.List;
+
+public record RequireItemAction(List items, String tag) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.REQUIRE_ITEM;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ if (items != null && !items.isEmpty()) {
+ JsonArray array = new JsonArray();
+ for (String item : items) {
+ array.add(item);
+ }
+ out.add("items", array);
+ }
+ if (tag != null && !tag.isBlank()) {
+ out.addProperty("tag", tag);
+ }
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ LocalPlayer player = ctx.client().player;
+ if (player == null) {
+ return ActionResult.abort();
+ }
+ return ItemLookup.findHotbarSlot(player.getInventory(), items, tag) >= 0
+ ? ActionResult.proceed() : ActionResult.abort();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/RestoreSlotAction.java b/src/main/java/com/kingjayan/keybinds/core/action/RestoreSlotAction.java
new file mode 100644
index 0000000..32d1659
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/RestoreSlotAction.java
@@ -0,0 +1,24 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.game.PlayerActions;
+import com.google.gson.JsonObject;
+
+public record RestoreSlotAction() implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.RESTORE_SLOT;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ if (ctx.startSlot() >= 0) {
+ PlayerActions.selectHotbarSlotIfValid(ctx.client(), ctx.startSlot());
+ }
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/SelectItemAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SelectItemAction.java
new file mode 100644
index 0000000..6d94bcc
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/SelectItemAction.java
@@ -0,0 +1,47 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.game.ItemLookup;
+import com.kingjayan.keybinds.game.PlayerActions;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.player.LocalPlayer;
+
+import java.util.List;
+
+public record SelectItemAction(List items, String tag) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.SELECT_ITEM;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ if (items != null && !items.isEmpty()) {
+ JsonArray array = new JsonArray();
+ for (String item : items) {
+ array.add(item);
+ }
+ out.add("items", array);
+ }
+ if (tag != null && !tag.isBlank()) {
+ out.addProperty("tag", tag);
+ }
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ Minecraft client = ctx.client();
+ LocalPlayer player = client.player;
+ if (player == null) {
+ return ActionResult.abort();
+ }
+ int slot = ItemLookup.findHotbarSlot(player.getInventory(), items, tag);
+ if (slot < 0) {
+ return ActionResult.abort();
+ }
+ PlayerActions.selectHotbarSlot(client, player.getInventory(), slot);
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/SelectSlotAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SelectSlotAction.java
new file mode 100644
index 0000000..0ccc5fe
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/SelectSlotAction.java
@@ -0,0 +1,23 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.game.PlayerActions;
+import com.google.gson.JsonObject;
+
+public record SelectSlotAction(int slot) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.SELECT_SLOT;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("slot", slot);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ PlayerActions.selectHotbarSlotIfValid(ctx.client(), slot);
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/ShieldTotemSwapAction.java b/src/main/java/com/kingjayan/keybinds/core/action/ShieldTotemSwapAction.java
new file mode 100644
index 0000000..de83f6d
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/ShieldTotemSwapAction.java
@@ -0,0 +1,58 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.game.PlayerActions;
+import com.google.gson.JsonObject;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.multiplayer.MultiPlayerGameMode;
+import net.minecraft.client.player.LocalPlayer;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.inventory.ContainerInput;
+import net.minecraft.world.inventory.InventoryMenu;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.Items;
+
+public record ShieldTotemSwapAction() implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.SHIELD_TOTEM_SWAP;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ Minecraft client = ctx.client();
+ LocalPlayer player = client.player;
+ MultiPlayerGameMode gameMode = client.gameMode;
+ if (player == null || gameMode == null) {
+ return ActionResult.abort();
+ }
+ if (player.containerMenu != player.inventoryMenu) {
+ return ActionResult.abort();
+ }
+
+ Inventory inventory = player.getInventory();
+ int containerId = player.inventoryMenu.containerId;
+ ItemStack offhand = player.getItemInHand(InteractionHand.OFF_HAND);
+
+ if (offhand.is(Items.SHIELD)) {
+ int totemSlot = PlayerActions.findItem(inventory, Items.TOTEM_OF_UNDYING);
+ if (totemSlot >= 0) {
+ PlayerActions.swapWithOffhand(gameMode, containerId, player, totemSlot);
+ } else {
+ gameMode.handleContainerInput(
+ containerId, InventoryMenu.SHIELD_SLOT, 0, ContainerInput.QUICK_MOVE, player);
+ }
+ } else {
+ int shieldSlot = PlayerActions.findItem(inventory, Items.SHIELD);
+ if (shieldSlot >= 0) {
+ PlayerActions.swapWithOffhand(gameMode, containerId, player, shieldSlot);
+ }
+ }
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/SneakAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SneakAction.java
new file mode 100644
index 0000000..5dc38b2
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/SneakAction.java
@@ -0,0 +1,32 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+import net.minecraft.client.KeyMapping;
+
+public record SneakAction(String mode) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.SNEAK;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("mode", mode);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ if (ctx.client() == null || ctx.client().player == null) {
+ return ActionResult.proceed();
+ }
+ KeyMapping shift = ctx.client().options.keyShift;
+ boolean target = switch (mode) {
+ case "on" -> true;
+ case "off" -> false;
+ default -> !shift.isDown();
+ };
+ shift.setDown(target);
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/SprintAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SprintAction.java
new file mode 100644
index 0000000..e097674
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/SprintAction.java
@@ -0,0 +1,33 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+import net.minecraft.client.player.LocalPlayer;
+
+public record SprintAction(String mode) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.SPRINT;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("mode", mode);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ LocalPlayer player = ctx.client() != null ? ctx.client().player : null;
+ if (player == null) {
+ return ActionResult.proceed();
+ }
+ boolean target = switch (mode) {
+ case "on" -> true;
+ case "off" -> false;
+ default -> !player.isSprinting();
+ };
+ ctx.client().options.keySprint.setDown(target);
+ player.setSprinting(target);
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/SwapBackAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SwapBackAction.java
new file mode 100644
index 0000000..e1ba808
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/SwapBackAction.java
@@ -0,0 +1,24 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+
+public record SwapBackAction() implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.SWAP_BACK;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ Runnable undo = ctx.popSwapBack();
+ if (undo != null) {
+ undo.run();
+ }
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/SwapToItemAction.java b/src/main/java/com/kingjayan/keybinds/core/action/SwapToItemAction.java
new file mode 100644
index 0000000..19f9212
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/SwapToItemAction.java
@@ -0,0 +1,91 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.game.ItemLookup;
+import com.kingjayan.keybinds.game.PlayerActions;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.player.LocalPlayer;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.item.ItemStack;
+
+import java.util.List;
+
+public record SwapToItemAction(List items, String rankEnchant) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.SWAP_TO_ITEM;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ if (items != null && !items.isEmpty()) {
+ JsonArray array = new JsonArray();
+ for (String item : items) {
+ array.add(item);
+ }
+ out.add("items", array);
+ }
+ if (rankEnchant != null && !rankEnchant.isBlank()) {
+ out.addProperty("rankEnchant", rankEnchant);
+ }
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ Minecraft client = ctx.client();
+ LocalPlayer player = client.player;
+ if (player == null || client.gameMode == null) {
+ return ActionResult.abort();
+ }
+ Inventory inventory = player.getInventory();
+
+ int bestSlot = findBestSlot(inventory);
+ if (bestSlot < 0) {
+ return ActionResult.abort();
+ }
+
+ if (bestSlot < Inventory.SELECTION_SIZE) {
+ int previousSlot = inventory.getSelectedSlot();
+ PlayerActions.selectHotbarSlot(client, inventory, bestSlot);
+ ctx.pushSwapBack(() -> PlayerActions.selectHotbarSlotIfValid(client, previousSlot));
+ } else {
+ if (player.containerMenu != player.inventoryMenu) {
+ return ActionResult.abort();
+ }
+ int hotbarSlot = inventory.getSelectedSlot();
+ int containerId = player.inventoryMenu.containerId;
+ PlayerActions.swapBuriedWithHeld(client, containerId, bestSlot, hotbarSlot);
+ ctx.pushSwapBack(() -> PlayerActions.swapBuriedWithHeld(client, containerId, bestSlot, hotbarSlot));
+ }
+ return ActionResult.proceed();
+ }
+
+ private int findBestSlot(Inventory inventory) {
+ boolean ranked = rankEnchant != null && !rankEnchant.isBlank();
+ int bestSlot = -1;
+ int bestLevel = Integer.MIN_VALUE;
+ for (int slot = 0; slot < PlayerActions.MAIN_INVENTORY_END; slot++) {
+ ItemStack stack = inventory.getItem(slot);
+ if (stack.isEmpty()) {
+ continue;
+ }
+ boolean candidate = items == null || items.isEmpty()
+ ? ranked && ItemLookup.enchantLevel(stack, rankEnchant) > 0
+ : ItemLookup.matchesAny(stack, items);
+ if (!candidate) {
+ continue;
+ }
+ if (!ranked) {
+ return slot;
+ }
+ int level = ItemLookup.enchantLevel(stack, rankEnchant);
+ if (level > bestLevel) {
+ bestLevel = level;
+ bestSlot = slot;
+ }
+ }
+ return bestSlot;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/TurnAction.java b/src/main/java/com/kingjayan/keybinds/core/action/TurnAction.java
new file mode 100644
index 0000000..47c92ec
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/TurnAction.java
@@ -0,0 +1,27 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+import net.minecraft.client.player.LocalPlayer;
+import net.minecraft.util.Mth;
+
+public record TurnAction(float degrees) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.TURN;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("degrees", degrees);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ LocalPlayer player = ctx.client() != null ? ctx.client().player : null;
+ if (player != null) {
+ player.setYRot(Mth.wrapDegrees(degrees));
+ }
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/UseAction.java b/src/main/java/com/kingjayan/keybinds/core/action/UseAction.java
new file mode 100644
index 0000000..bd75805
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/UseAction.java
@@ -0,0 +1,23 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.game.PlayerActions;
+import com.google.gson.JsonObject;
+import net.minecraft.world.InteractionHand;
+
+public record UseAction() implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.USE;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ PlayerActions.useHand(ctx.client(), InteractionHand.MAIN_HAND);
+ return ActionResult.proceed();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/UseItemAction.java b/src/main/java/com/kingjayan/keybinds/core/action/UseItemAction.java
new file mode 100644
index 0000000..644e1cf
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/UseItemAction.java
@@ -0,0 +1,114 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.kingjayan.keybinds.core.schedule.TickScheduler;
+import com.kingjayan.keybinds.game.ItemLookup;
+import com.kingjayan.keybinds.game.PlayerActions;
+import com.google.gson.JsonObject;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.player.LocalPlayer;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.item.Item;
+
+public record UseItemAction(String item, String hotbarGate, boolean vanillaSwap,
+ int swapToDelay, int useDelay, int swapBackDelay) implements Action {
+
+ public static final String GATE_ANY = "any";
+ public static final String GATE_IN_HOTBAR = "in_hotbar";
+ public static final String GATE_NOT_IN_HOTBAR = "not_in_hotbar";
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.USE_ITEM;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("item", item);
+ out.addProperty("hotbarGate", hotbarGate);
+ out.addProperty("vanillaSwap", vanillaSwap);
+ out.addProperty("swapToDelay", swapToDelay);
+ out.addProperty("useDelay", useDelay);
+ out.addProperty("swapBackDelay", swapBackDelay);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ Minecraft client = ctx.client();
+ LocalPlayer player = client.player;
+ if (player == null || client.gameMode == null) {
+ return ActionResult.abort();
+ }
+ Item resolved = ItemLookup.resolveItem(item);
+ if (resolved == null) {
+ return ActionResult.abort();
+ }
+
+ Inventory inventory = player.getInventory();
+ boolean inMainHand = player.getMainHandItem().is(resolved);
+ boolean inHands = inMainHand || player.getOffhandItem().is(resolved);
+ int hotbarSlot = PlayerActions.findHotbarSlot(inventory, resolved);
+ boolean onHotbar = inHands || hotbarSlot >= 0;
+
+ String gate = hotbarGate == null ? GATE_ANY : hotbarGate;
+ if (gate.equals(GATE_IN_HOTBAR) && !onHotbar) {
+ return ActionResult.abort();
+ }
+ if (gate.equals(GATE_NOT_IN_HOTBAR) && onHotbar) {
+ return ActionResult.abort();
+ }
+
+ int buriedSlot = -1;
+ if (!onHotbar && player.containerMenu == player.inventoryMenu) {
+ for (int slot = Inventory.SELECTION_SIZE; slot < PlayerActions.MAIN_INVENTORY_END; slot++) {
+ if (inventory.getItem(slot).is(resolved)) {
+ buriedSlot = slot;
+ break;
+ }
+ }
+ }
+
+ if (vanillaSwap && buriedSlot >= 0) {
+ scheduleBuriedSwap(ctx, buriedSlot);
+ } else if (vanillaSwap && !inMainHand && hotbarSlot >= 0) {
+ scheduleHotbarUse(ctx, hotbarSlot);
+ } else {
+ PlayerActions.useItemInstant(client, resolved);
+ }
+ return ActionResult.proceed();
+ }
+
+ private void scheduleHotbarUse(ActionContext ctx, int hotbarSlot) {
+ Minecraft client = ctx.client();
+ int previousSlot = client.player.getInventory().getSelectedSlot();
+
+ int atSwapTo = swapToDelay;
+ int atUse = atSwapTo + useDelay;
+ int atSwapBack = atUse + swapBackDelay;
+
+ TickScheduler scheduler = ctx.scheduler();
+ scheduler.schedule(atSwapTo, () -> PlayerActions.selectHotbarSlotIfValid(client, hotbarSlot));
+ scheduler.schedule(atUse, () -> PlayerActions.useHand(client, InteractionHand.MAIN_HAND));
+ scheduler.schedule(atSwapBack, () -> PlayerActions.selectHotbarSlotIfValid(client, previousSlot));
+ }
+
+ private void scheduleBuriedSwap(ActionContext ctx, int buriedSlot) {
+ Minecraft client = ctx.client();
+ LocalPlayer player = client.player;
+ if (player.containerMenu != player.inventoryMenu) {
+ return;
+ }
+ Inventory inventory = player.getInventory();
+ int hotbarSlot = inventory.getSelectedSlot();
+ int containerId = player.inventoryMenu.containerId;
+
+ int atSwapTo = swapToDelay;
+ int atUse = atSwapTo + useDelay;
+ int atSwapBack = atUse + swapBackDelay;
+
+ TickScheduler scheduler = ctx.scheduler();
+ scheduler.schedule(atSwapTo, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot));
+ scheduler.schedule(atUse, () -> PlayerActions.useHand(client, InteractionHand.MAIN_HAND));
+ scheduler.schedule(atSwapBack, () -> PlayerActions.swapBuriedWithHeld(client, containerId, buriedSlot, hotbarSlot));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/action/WaitAction.java b/src/main/java/com/kingjayan/keybinds/core/action/WaitAction.java
new file mode 100644
index 0000000..e709ff3
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/action/WaitAction.java
@@ -0,0 +1,21 @@
+package com.kingjayan.keybinds.core.action;
+
+import com.google.gson.JsonObject;
+
+public record WaitAction(int ticks) implements Action {
+
+ @Override
+ public ActionType type() {
+ return ActionTypes.WAIT;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("ticks", ticks);
+ }
+
+ @Override
+ public ActionResult execute(ActionContext ctx) {
+ return ActionResult.delay(ticks);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/condition/PlayerConditions.java b/src/main/java/com/kingjayan/keybinds/core/condition/PlayerConditions.java
new file mode 100644
index 0000000..2ab7ea6
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/condition/PlayerConditions.java
@@ -0,0 +1,41 @@
+package com.kingjayan.keybinds.core.condition;
+
+import net.minecraft.client.player.LocalPlayer;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+
+public final class PlayerConditions {
+
+ private static final Map> CONDITIONS = new LinkedHashMap<>();
+
+ static {
+ register("low_health", p -> p.getHealth() <= 6.0f);
+ register("on_fire", LocalPlayer::isOnFire);
+ register("in_lava", LocalPlayer::isInLava);
+ register("falling", p -> p.fallDistance > 3.0f);
+ register("in_water", LocalPlayer::isInWater);
+ register("submerged", LocalPlayer::isUnderWater);
+ register("hurt", p -> p.hurtTime > 0);
+ register("sneaking", LocalPlayer::isShiftKeyDown);
+ register("sprinting", LocalPlayer::isSprinting);
+ }
+
+ private PlayerConditions() {
+ }
+
+ public static void register(String id, Predicate test) {
+ CONDITIONS.put(id, test);
+ }
+
+ public static boolean active(String id, LocalPlayer player) {
+ Predicate test = CONDITIONS.get(id);
+ return test != null && player != null && test.test(player);
+ }
+
+ public static List ids() {
+ return List.copyOf(CONDITIONS.keySet());
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/exec/ExecutionEngine.java b/src/main/java/com/kingjayan/keybinds/core/exec/ExecutionEngine.java
new file mode 100644
index 0000000..a58713f
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/exec/ExecutionEngine.java
@@ -0,0 +1,47 @@
+package com.kingjayan.keybinds.core.exec;
+
+import com.kingjayan.keybinds.core.action.ActionContext;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.schedule.TickScheduler;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.player.LocalPlayer;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class ExecutionEngine {
+
+ private final List executions = new ArrayList<>();
+
+ public void tick() {
+ if (executions.isEmpty()) {
+ return;
+ }
+ for (int i = 0; i < executions.size(); i++) {
+ executions.get(i).tick();
+ }
+ executions.removeIf(MacroExecution::finished);
+ }
+
+ public boolean running(Macro macro) {
+ for (MacroExecution execution : executions) {
+ if (execution.macro() == macro && !execution.finished()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void start(Macro macro, Minecraft client, TickScheduler scheduler) {
+ if (running(macro)) {
+ return;
+ }
+ LocalPlayer player = client.player;
+ int startSlot = player != null ? player.getInventory().getSelectedSlot() : -1;
+ MacroExecution execution = new MacroExecution(macro, new ActionContext(client, scheduler, startSlot));
+ execution.run();
+ if (!execution.finished()) {
+ executions.add(execution);
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/exec/MacroExecution.java b/src/main/java/com/kingjayan/keybinds/core/exec/MacroExecution.java
new file mode 100644
index 0000000..ed6052f
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/exec/MacroExecution.java
@@ -0,0 +1,58 @@
+package com.kingjayan.keybinds.core.exec;
+
+import com.kingjayan.keybinds.core.action.Action;
+import com.kingjayan.keybinds.core.action.ActionContext;
+import com.kingjayan.keybinds.core.action.ActionResult;
+import com.kingjayan.keybinds.core.macro.Macro;
+
+import java.util.List;
+
+public final class MacroExecution {
+
+ private final Macro macro;
+ private final List actions;
+ private final ActionContext context;
+
+ private int index;
+ private int delay;
+ private boolean aborted;
+
+ public MacroExecution(Macro macro, ActionContext context) {
+ this.macro = macro;
+ this.actions = List.copyOf(macro.actions());
+ this.context = context;
+ }
+
+ public Macro macro() {
+ return macro;
+ }
+
+ public boolean finished() {
+ return aborted || index >= actions.size();
+ }
+
+ public void run() {
+ while (!finished()) {
+ ActionResult result = actions.get(index).execute(context);
+ if (result.aborted()) {
+ aborted = true;
+ return;
+ }
+ index++;
+ if (result.delayTicks() > 0) {
+ delay = result.delayTicks();
+ return;
+ }
+ }
+ }
+
+ public void tick() {
+ if (finished() || delay <= 0) {
+ return;
+ }
+ delay--;
+ if (delay <= 0) {
+ run();
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/input/EdgeDetector.java b/src/main/java/com/kingjayan/keybinds/core/input/EdgeDetector.java
new file mode 100644
index 0000000..6dbed8b
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/input/EdgeDetector.java
@@ -0,0 +1,35 @@
+package com.kingjayan.keybinds.core.input;
+
+import java.util.HashSet;
+import java.util.Set;
+
+public final class EdgeDetector {
+
+ private final Set downLastTick = new HashSet<>();
+ private final Set downNow = new HashSet<>();
+ private boolean resync = true;
+
+ public boolean justPressed(int code, boolean down) {
+ if (down) {
+ downNow.add(code);
+ }
+ return down && !downLastTick.contains(code);
+ }
+
+ public boolean resyncing() {
+ return resync;
+ }
+
+ public void commit() {
+ downLastTick.clear();
+ downLastTick.addAll(downNow);
+ downNow.clear();
+ resync = false;
+ }
+
+ public void reset() {
+ downLastTick.clear();
+ downNow.clear();
+ resync = true;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/input/KeyNames.java b/src/main/java/com/kingjayan/keybinds/core/input/KeyNames.java
new file mode 100644
index 0000000..0094369
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/input/KeyNames.java
@@ -0,0 +1,105 @@
+package com.kingjayan.keybinds.core.input;
+
+import org.lwjgl.glfw.GLFW;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class KeyNames {
+
+ private static final Map KEYS = new LinkedHashMap<>();
+ private static final Map KEY_NAMES = new LinkedHashMap<>();
+ private static final Map BUTTONS = new LinkedHashMap<>();
+ private static final Map BUTTON_NAMES = new LinkedHashMap<>();
+
+ static {
+ for (char c = 'a'; c <= 'z'; c++) {
+ key(String.valueOf(c), GLFW.GLFW_KEY_A + (c - 'a'));
+ }
+ for (char c = '0'; c <= '9'; c++) {
+ key(String.valueOf(c), GLFW.GLFW_KEY_0 + (c - '0'));
+ }
+ for (int i = 1; i <= 25; i++) {
+ key("f" + i, GLFW.GLFW_KEY_F1 + (i - 1));
+ }
+ key("space", GLFW.GLFW_KEY_SPACE);
+ key("escape", GLFW.GLFW_KEY_ESCAPE);
+ key("enter", GLFW.GLFW_KEY_ENTER);
+ key("tab", GLFW.GLFW_KEY_TAB);
+ key("backspace", GLFW.GLFW_KEY_BACKSPACE);
+ key("insert", GLFW.GLFW_KEY_INSERT);
+ key("delete", GLFW.GLFW_KEY_DELETE);
+ key("right", GLFW.GLFW_KEY_RIGHT);
+ key("left", GLFW.GLFW_KEY_LEFT);
+ key("down", GLFW.GLFW_KEY_DOWN);
+ key("up", GLFW.GLFW_KEY_UP);
+ key("page_up", GLFW.GLFW_KEY_PAGE_UP);
+ key("page_down", GLFW.GLFW_KEY_PAGE_DOWN);
+ key("home", GLFW.GLFW_KEY_HOME);
+ key("end", GLFW.GLFW_KEY_END);
+ key("caps_lock", GLFW.GLFW_KEY_CAPS_LOCK);
+ key("left_shift", GLFW.GLFW_KEY_LEFT_SHIFT);
+ key("left_control", GLFW.GLFW_KEY_LEFT_CONTROL);
+ key("left_alt", GLFW.GLFW_KEY_LEFT_ALT);
+ key("right_shift", GLFW.GLFW_KEY_RIGHT_SHIFT);
+ key("right_control", GLFW.GLFW_KEY_RIGHT_CONTROL);
+ key("right_alt", GLFW.GLFW_KEY_RIGHT_ALT);
+ key("apostrophe", GLFW.GLFW_KEY_APOSTROPHE);
+ key("comma", GLFW.GLFW_KEY_COMMA);
+ key("minus", GLFW.GLFW_KEY_MINUS);
+ key("period", GLFW.GLFW_KEY_PERIOD);
+ key("slash", GLFW.GLFW_KEY_SLASH);
+ key("semicolon", GLFW.GLFW_KEY_SEMICOLON);
+ key("equal", GLFW.GLFW_KEY_EQUAL);
+ key("left_bracket", GLFW.GLFW_KEY_LEFT_BRACKET);
+ key("backslash", GLFW.GLFW_KEY_BACKSLASH);
+ key("right_bracket", GLFW.GLFW_KEY_RIGHT_BRACKET);
+ key("grave", GLFW.GLFW_KEY_GRAVE_ACCENT);
+ button("left", GLFW.GLFW_MOUSE_BUTTON_LEFT);
+ button("right", GLFW.GLFW_MOUSE_BUTTON_RIGHT);
+ button("middle", GLFW.GLFW_MOUSE_BUTTON_MIDDLE);
+ button("button4", GLFW.GLFW_MOUSE_BUTTON_4);
+ button("button5", GLFW.GLFW_MOUSE_BUTTON_5);
+ button("button6", GLFW.GLFW_MOUSE_BUTTON_6);
+ button("button7", GLFW.GLFW_MOUSE_BUTTON_7);
+ button("button8", GLFW.GLFW_MOUSE_BUTTON_8);
+ }
+
+ private KeyNames() {
+ }
+
+ private static void key(String name, int code) {
+ KEYS.put(name, code);
+ KEY_NAMES.putIfAbsent(code, name);
+ }
+
+ private static void button(String name, int code) {
+ BUTTONS.put(name, code);
+ BUTTON_NAMES.putIfAbsent(code, name);
+ }
+
+ public static Integer keyCode(String name) {
+ return KEYS.get(name.toLowerCase());
+ }
+
+ public static Integer buttonCode(String name) {
+ return BUTTONS.get(name.toLowerCase());
+ }
+
+ public static String keyName(int code) {
+ return KEY_NAMES.get(code);
+ }
+
+ public static String buttonName(int code) {
+ return BUTTON_NAMES.get(code);
+ }
+
+ public static List keyNames() {
+ return List.copyOf(KEYS.keySet());
+ }
+
+ public static List buttonNames() {
+ return List.copyOf(BUTTONS.keySet());
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java b/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java
new file mode 100644
index 0000000..075024c
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/macro/Macro.java
@@ -0,0 +1,70 @@
+package com.kingjayan.keybinds.core.macro;
+
+import com.kingjayan.keybinds.core.action.Action;
+import com.kingjayan.keybinds.core.trigger.Trigger;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+public final class Macro {
+
+ private final UUID id;
+ private String name;
+ private boolean enabled;
+ private Trigger trigger;
+ private final List actions;
+ private String script;
+
+ public Macro(UUID id, String name, boolean enabled, Trigger trigger, List actions) {
+ this.id = id;
+ this.name = name;
+ this.enabled = enabled;
+ this.trigger = trigger;
+ this.actions = new ArrayList<>(actions);
+ }
+
+ public static Macro create(String name) {
+ return new Macro(UUID.randomUUID(), name, true, null, List.of());
+ }
+
+ public UUID id() {
+ return id;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public boolean enabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public Trigger trigger() {
+ return trigger;
+ }
+
+ public void setTrigger(Trigger trigger) {
+ this.trigger = trigger;
+ }
+
+ public List actions() {
+ return actions;
+ }
+
+ public String script() {
+ return script;
+ }
+
+ public void setScript(String script) {
+ this.script = script;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java b/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java
new file mode 100644
index 0000000..14bf285
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/macro/MacroRegistry.java
@@ -0,0 +1,55 @@
+package com.kingjayan.keybinds.core.macro;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+public final class MacroRegistry {
+
+ private static final MacroRegistry INSTANCE = new MacroRegistry();
+
+ private final List macros = new ArrayList<>();
+ private int revision;
+
+ public static MacroRegistry get() {
+ return INSTANCE;
+ }
+
+ public List all() {
+ return Collections.unmodifiableList(macros);
+ }
+
+ public Macro byId(UUID id) {
+ for (Macro macro : macros) {
+ if (macro.id().equals(id)) {
+ return macro;
+ }
+ }
+ return null;
+ }
+
+ public void add(Macro macro) {
+ macros.add(macro);
+ revision++;
+ }
+
+ public void remove(Macro macro) {
+ macros.remove(macro);
+ revision++;
+ }
+
+ public void replaceAll(List replacement) {
+ macros.clear();
+ macros.addAll(replacement);
+ revision++;
+ }
+
+ public void touch() {
+ revision++;
+ }
+
+ public int revision() {
+ return revision;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/schedule/TickScheduler.java b/src/main/java/com/kingjayan/keybinds/core/schedule/TickScheduler.java
new file mode 100644
index 0000000..d6e5556
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/schedule/TickScheduler.java
@@ -0,0 +1,40 @@
+package com.kingjayan.keybinds.core.schedule;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+public final class TickScheduler {
+
+ private final List tasks = new ArrayList<>();
+
+ private static final class Task {
+ int ticks;
+ final Runnable action;
+
+ Task(int ticks, Runnable action) {
+ this.ticks = ticks;
+ this.action = action;
+ }
+ }
+
+ public void schedule(int delayTicks, Runnable action) {
+ tasks.add(new Task(delayTicks, action));
+ }
+
+ public void tick() {
+ if (tasks.isEmpty()) {
+ return;
+ }
+ Iterator it = tasks.iterator();
+ while (it.hasNext()) {
+ Task task = it.next();
+ if (task.ticks <= 0) {
+ task.action.run();
+ it.remove();
+ } else {
+ task.ticks--;
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/AllOfTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/AllOfTrigger.java
new file mode 100644
index 0000000..51749f3
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/AllOfTrigger.java
@@ -0,0 +1,35 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.List;
+
+public record AllOfTrigger(List children) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.ALL_OF;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ JsonArray array = new JsonArray();
+ for (Trigger child : children) {
+ array.add(TriggerRegistry.toJson(child));
+ }
+ out.add("children", array);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = true;
+ boolean childFired = false;
+ for (Trigger child : children) {
+ TriggerResult result = child.evaluate(ctx);
+ active &= result.active();
+ childFired |= result.fired();
+ }
+ return new TriggerResult(active, active && childFired);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/AnyOfTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/AnyOfTrigger.java
new file mode 100644
index 0000000..24aa5c7
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/AnyOfTrigger.java
@@ -0,0 +1,35 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.List;
+
+public record AnyOfTrigger(List children) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.ANY_OF;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ JsonArray array = new JsonArray();
+ for (Trigger child : children) {
+ array.add(TriggerRegistry.toJson(child));
+ }
+ out.add("children", array);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = false;
+ boolean fired = false;
+ for (Trigger child : children) {
+ TriggerResult result = child.evaluate(ctx);
+ active |= result.active();
+ fired |= result.fired();
+ }
+ return new TriggerResult(active, fired);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/ConditionTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/ConditionTrigger.java
new file mode 100644
index 0000000..1f30406
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/ConditionTrigger.java
@@ -0,0 +1,22 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record ConditionTrigger(String condition) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.CONDITION;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("condition", condition);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = ctx.conditionActive(condition);
+ return new TriggerResult(active, ctx.edge(this, active));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/HeldDurabilityTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/HeldDurabilityTrigger.java
new file mode 100644
index 0000000..483f44a
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/HeldDurabilityTrigger.java
@@ -0,0 +1,22 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record HeldDurabilityTrigger(int durability) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.HELD_DURABILITY;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("durability", durability);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = ctx.heldDurabilityAtMost(durability);
+ return new TriggerResult(active, ctx.edge(this, active));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/HoldingTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/HoldingTrigger.java
new file mode 100644
index 0000000..2ab983c
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/HoldingTrigger.java
@@ -0,0 +1,28 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.List;
+
+public record HoldingTrigger(List items) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.HOLDING;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ JsonArray array = new JsonArray();
+ for (String item : items) {
+ array.add(item);
+ }
+ out.add("items", array);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ return new TriggerResult(ctx.holdingAny(items), false);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/KeyComboTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyComboTrigger.java
new file mode 100644
index 0000000..9c06ef3
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyComboTrigger.java
@@ -0,0 +1,101 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.kingjayan.keybinds.core.input.KeyNames;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.List;
+import java.util.function.IntPredicate;
+
+public final class KeyComboTrigger implements Trigger {
+
+ private final List keys;
+ private final int[][] codes;
+
+ public KeyComboTrigger(List keys) {
+ this.keys = List.copyOf(keys);
+ this.codes = resolveAll(this.keys);
+ }
+
+ public List keys() {
+ return keys;
+ }
+
+ public int[][] codes() {
+ return codes;
+ }
+
+ public static int[][] resolveAll(List keys) {
+ int[][] codes = new int[keys.size()][];
+ for (int i = 0; i < keys.size(); i++) {
+ codes[i] = resolve(keys.get(i));
+ }
+ return codes;
+ }
+
+ public static int[] resolve(String name) {
+ String lower = name.trim().toLowerCase();
+ switch (lower) {
+ case "ctrl", "control" -> {
+ return new int[] {GLFW.GLFW_KEY_LEFT_CONTROL, GLFW.GLFW_KEY_RIGHT_CONTROL};
+ }
+ case "shift" -> {
+ return new int[] {GLFW.GLFW_KEY_LEFT_SHIFT, GLFW.GLFW_KEY_RIGHT_SHIFT};
+ }
+ case "alt" -> {
+ return new int[] {GLFW.GLFW_KEY_LEFT_ALT, GLFW.GLFW_KEY_RIGHT_ALT};
+ }
+ default -> {
+ Integer code = KeyNames.keyCode(lower);
+ if (code != null) {
+ return new int[] {code};
+ }
+ try {
+ return new int[] {Integer.parseInt(lower)};
+ } catch (NumberFormatException e) {
+ return new int[0];
+ }
+ }
+ }
+ }
+
+ public static boolean allDown(int[][] codes, IntPredicate keyDown) {
+ if (codes.length == 0) {
+ return false;
+ }
+ for (int[] alternatives : codes) {
+ boolean down = false;
+ for (int code : alternatives) {
+ if (keyDown.test(code)) {
+ down = true;
+ break;
+ }
+ }
+ if (!down) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.KEY_COMBO;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ JsonArray array = new JsonArray();
+ for (String key : keys) {
+ array.add(key);
+ }
+ out.add("keys", array);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = allDown(codes, ctx::keyDown);
+ return new TriggerResult(active, ctx.edge(this, active));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/KeyReleaseTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyReleaseTrigger.java
new file mode 100644
index 0000000..c992b45
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyReleaseTrigger.java
@@ -0,0 +1,25 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record KeyReleaseTrigger(int key) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.KEY_RELEASE;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("key", key);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ if (key < 0) {
+ return new TriggerResult(false, false);
+ }
+ boolean released = ctx.edge(this, !ctx.keyDown(key));
+ return new TriggerResult(released, released);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/KeyTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyTrigger.java
new file mode 100644
index 0000000..2f3dee1
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/KeyTrigger.java
@@ -0,0 +1,22 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record KeyTrigger(int key) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.KEY;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("key", key);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = ctx.keyDown(key);
+ return new TriggerResult(active, ctx.edge(this, active));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/MouseButtonTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/MouseButtonTrigger.java
new file mode 100644
index 0000000..903677c
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/MouseButtonTrigger.java
@@ -0,0 +1,22 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record MouseButtonTrigger(int button) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.MOUSE_BUTTON;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("button", button);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = ctx.mouseDown(button);
+ return new TriggerResult(active, ctx.edge(this, active));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/MouseReleaseTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/MouseReleaseTrigger.java
new file mode 100644
index 0000000..d38c006
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/MouseReleaseTrigger.java
@@ -0,0 +1,25 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record MouseReleaseTrigger(int button) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.MOUSE_RELEASE;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("button", button);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ if (button < 0) {
+ return new TriggerResult(false, false);
+ }
+ boolean released = ctx.edge(this, !ctx.mouseDown(button));
+ return new TriggerResult(released, released);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/NotTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/NotTrigger.java
new file mode 100644
index 0000000..9db80f7
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/NotTrigger.java
@@ -0,0 +1,22 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record NotTrigger(Trigger child) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.NOT;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.add("child", TriggerRegistry.toJson(child));
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ TriggerResult result = child.evaluate(ctx);
+ return new TriggerResult(!result.active(), false);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/RidingTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/RidingTrigger.java
new file mode 100644
index 0000000..35c7f8c
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/RidingTrigger.java
@@ -0,0 +1,21 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record RidingTrigger() implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.RIDING;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = ctx.riding();
+ return new TriggerResult(active, ctx.edge(this, active));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/SlotSelectedTrigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/SlotSelectedTrigger.java
new file mode 100644
index 0000000..1977e6c
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/SlotSelectedTrigger.java
@@ -0,0 +1,22 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public record SlotSelectedTrigger(int slot) implements Trigger {
+
+ @Override
+ public TriggerType type() {
+ return TriggerTypes.SLOT_SELECTED;
+ }
+
+ @Override
+ public void write(JsonObject out) {
+ out.addProperty("slot", slot);
+ }
+
+ @Override
+ public TriggerResult evaluate(TriggerContext ctx) {
+ boolean active = slot >= 0 && ctx.selectedSlot() == slot;
+ return new TriggerResult(active, ctx.edge(this, active));
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/Trigger.java b/src/main/java/com/kingjayan/keybinds/core/trigger/Trigger.java
new file mode 100644
index 0000000..53f549b
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/Trigger.java
@@ -0,0 +1,12 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+public interface Trigger {
+
+ TriggerType type();
+
+ void write(JsonObject out);
+
+ TriggerResult evaluate(TriggerContext ctx);
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java
new file mode 100644
index 0000000..ea319c2
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerContext.java
@@ -0,0 +1,84 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.kingjayan.keybinds.core.condition.PlayerConditions;
+import com.kingjayan.keybinds.game.ItemLookup;
+import com.mojang.blaze3d.platform.InputConstants;
+import net.minecraft.client.Minecraft;
+import net.minecraft.world.item.ItemStack;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class TriggerContext {
+
+ private Map previous = new IdentityHashMap<>();
+ private Map current = new IdentityHashMap<>();
+ private boolean resync = true;
+ private Minecraft client;
+
+ public void begin(Minecraft client) {
+ this.client = client;
+ }
+
+ public boolean keyDown(int key) {
+ return key >= 0 && client != null && InputConstants.isKeyDown(client.getWindow(), key);
+ }
+
+ public boolean mouseDown(int button) {
+ return button >= 0 && client != null
+ && GLFW.glfwGetMouseButton(client.getWindow().handle(), button) == GLFW.GLFW_PRESS;
+ }
+
+ public boolean conditionActive(String id) {
+ return client != null && PlayerConditions.active(id, client.player);
+ }
+
+ public int selectedSlot() {
+ return client != null && client.player != null
+ ? client.player.getInventory().getSelectedSlot() : -1;
+ }
+
+ public boolean riding() {
+ return client != null && client.player != null && client.player.isPassenger();
+ }
+
+ public boolean heldDurabilityAtMost(int durability) {
+ if (client == null || client.player == null) {
+ return false;
+ }
+ ItemStack held = client.player.getMainHandItem();
+ return held.isDamageableItem() && held.getMaxDamage() - held.getDamageValue() <= durability;
+ }
+
+ public boolean holdingAny(List items) {
+ if (items == null || items.isEmpty()) {
+ return true;
+ }
+ if (client == null || client.player == null) {
+ return false;
+ }
+ return ItemLookup.matchesAny(client.player.getMainHandItem(), items);
+ }
+
+ public boolean edge(Trigger node, boolean active) {
+ current.put(node, active);
+ boolean was = previous.getOrDefault(node, false);
+ return active && !was && !resync;
+ }
+
+ public void commit() {
+ Map swap = previous;
+ previous = current;
+ current = swap;
+ current.clear();
+ resync = false;
+ }
+
+ public void reset() {
+ previous.clear();
+ current.clear();
+ resync = true;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java
new file mode 100644
index 0000000..f681da2
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerIndex.java
@@ -0,0 +1,125 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.kingjayan.keybinds.core.macro.Macro;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+public final class TriggerIndex {
+
+ private final Map> byKey = new HashMap<>();
+ private final Map> byMouseButton = new HashMap<>();
+ private final List polled = new ArrayList<>();
+ private final Map order = new IdentityHashMap<>();
+ private final List candidateBuffer = new ArrayList<>();
+ private final Set seenBuffer = Collections.newSetFromMap(new IdentityHashMap<>());
+ private final Comparator byOrder = Comparator.comparingInt(order::get);
+
+ public void rebuild(List macros) {
+ byKey.clear();
+ byMouseButton.clear();
+ polled.clear();
+ order.clear();
+ index(macros);
+ }
+
+ public List candidates(TriggerContext ctx) {
+ List result = candidateBuffer;
+ Set seen = seenBuffer;
+ result.clear();
+ seen.clear();
+ result.addAll(polled);
+ seen.addAll(polled);
+ for (Map.Entry> entry : byKey.entrySet()) {
+ if (ctx.keyDown(entry.getKey())) {
+ addUnique(result, seen, entry.getValue());
+ }
+ }
+ for (Map.Entry> entry : byMouseButton.entrySet()) {
+ if (ctx.mouseDown(entry.getKey())) {
+ addUnique(result, seen, entry.getValue());
+ }
+ }
+ result.sort(byOrder);
+ return result;
+ }
+
+ private void addUnique(List result, Set seen, List macros) {
+ for (Macro macro : macros) {
+ if (seen.add(macro)) {
+ result.add(macro);
+ }
+ }
+ }
+
+ private void index(List macros) {
+ for (Macro macro : macros) {
+ if (macro.trigger() == null) {
+ continue;
+ }
+ Classification classification = new Classification();
+ classify(macro.trigger(), classification);
+ if (classification.polled
+ || (classification.keys.isEmpty() && classification.buttons.isEmpty())) {
+ order.put(macro, order.size());
+ polled.add(macro);
+ } else {
+ order.put(macro, order.size());
+ for (int key : classification.keys) {
+ byKey.computeIfAbsent(key, unused -> new ArrayList<>()).add(macro);
+ }
+ for (int button : classification.buttons) {
+ byMouseButton.computeIfAbsent(button, unused -> new ArrayList<>()).add(macro);
+ }
+ }
+ }
+ }
+
+ private void classify(Trigger trigger, Classification out) {
+ switch (trigger) {
+ case KeyTrigger key -> {
+ if (key.key() >= 0) {
+ out.keys.add(key.key());
+ }
+ }
+ case MouseButtonTrigger mouse -> {
+ if (mouse.button() >= 0) {
+ out.buttons.add(mouse.button());
+ }
+ }
+ case KeyComboTrigger combo -> {
+ for (int[] alternatives : combo.codes()) {
+ for (int code : alternatives) {
+ out.keys.add(code);
+ }
+ }
+ }
+ case AllOfTrigger all -> {
+ for (Trigger child : all.children()) {
+ classify(child, out);
+ }
+ }
+ case AnyOfTrigger any -> {
+ for (Trigger child : any.children()) {
+ classify(child, out);
+ }
+ }
+ case NotTrigger _, HoldingTrigger _ -> {
+ }
+ default -> out.polled = true;
+ }
+ }
+
+ private static final class Classification {
+ final Set keys = new LinkedHashSet<>();
+ final Set buttons = new LinkedHashSet<>();
+ boolean polled;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerRegistry.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerRegistry.java
new file mode 100644
index 0000000..6315281
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerRegistry.java
@@ -0,0 +1,49 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+import net.minecraft.util.GsonHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+public final class TriggerRegistry {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger("keybinds");
+ private static final Map TYPES = new LinkedHashMap<>();
+
+ private TriggerRegistry() {
+ }
+
+ public static TriggerType register(TriggerType type) {
+ TYPES.put(type.id(), type);
+ return type;
+ }
+
+ public static TriggerType byId(String id) {
+ return TYPES.get(id);
+ }
+
+ public static List ids() {
+ return List.copyOf(TYPES.keySet());
+ }
+
+ public static JsonObject toJson(Trigger trigger) {
+ JsonObject out = new JsonObject();
+ out.addProperty("type", trigger.type().id());
+ trigger.write(out);
+ return out;
+ }
+
+ public static Trigger fromJson(JsonObject json) {
+ String id = GsonHelper.getAsString(json, "type", "");
+ TriggerType type = TYPES.get(id);
+ if (type == null) {
+ LOGGER.warn("Unknown trigger type '{}', skipping", id);
+ return null;
+ }
+ return type.reader().apply(json);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerResult.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerResult.java
new file mode 100644
index 0000000..54d04d9
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerResult.java
@@ -0,0 +1,4 @@
+package com.kingjayan.keybinds.core.trigger;
+
+public record TriggerResult(boolean active, boolean fired) {
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerType.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerType.java
new file mode 100644
index 0000000..02c821c
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerType.java
@@ -0,0 +1,8 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonObject;
+
+import java.util.function.Function;
+
+public record TriggerType(String id, Function reader) {
+}
diff --git a/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java
new file mode 100644
index 0000000..45c55a4
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/core/trigger/TriggerTypes.java
@@ -0,0 +1,85 @@
+package com.kingjayan.keybinds.core.trigger;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import net.minecraft.util.GsonHelper;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class TriggerTypes {
+
+ public static final TriggerType KEY = TriggerRegistry.register(new TriggerType("key",
+ json -> new KeyTrigger(GsonHelper.getAsInt(json, "key", GLFW.GLFW_KEY_UNKNOWN))));
+
+ public static final TriggerType MOUSE_BUTTON = TriggerRegistry.register(new TriggerType("mouse_button",
+ json -> new MouseButtonTrigger(GsonHelper.getAsInt(json, "button", -1))));
+
+ public static final TriggerType CONDITION = TriggerRegistry.register(new TriggerType("condition",
+ json -> new ConditionTrigger(GsonHelper.getAsString(json, "condition", ""))));
+
+ public static final TriggerType ALL_OF = TriggerRegistry.register(new TriggerType("all_of",
+ json -> new AllOfTrigger(readChildren(json))));
+
+ public static final TriggerType ANY_OF = TriggerRegistry.register(new TriggerType("any_of",
+ json -> new AnyOfTrigger(readChildren(json))));
+
+ public static final TriggerType NOT = TriggerRegistry.register(new TriggerType("not",
+ json -> {
+ Trigger child = json.get("child") instanceof JsonObject childJson
+ ? TriggerRegistry.fromJson(childJson) : null;
+ return child == null ? null : new NotTrigger(child);
+ }));
+
+ public static final TriggerType HOLDING = TriggerRegistry.register(new TriggerType("holding",
+ json -> new HoldingTrigger(readStrings(json, "items"))));
+
+ public static final TriggerType KEY_COMBO = TriggerRegistry.register(new TriggerType("key_combo",
+ json -> new KeyComboTrigger(readStrings(json, "keys"))));
+
+ public static final TriggerType KEY_RELEASE = TriggerRegistry.register(new TriggerType("key_release",
+ json -> new KeyReleaseTrigger(GsonHelper.getAsInt(json, "key", GLFW.GLFW_KEY_UNKNOWN))));
+
+ public static final TriggerType MOUSE_RELEASE = TriggerRegistry.register(new TriggerType("mouse_release",
+ json -> new MouseReleaseTrigger(GsonHelper.getAsInt(json, "button", -1))));
+
+ public static final TriggerType SLOT_SELECTED = TriggerRegistry.register(new TriggerType("slot_selected",
+ json -> new SlotSelectedTrigger(GsonHelper.getAsInt(json, "slot", -1))));
+
+ public static final TriggerType RIDING = TriggerRegistry.register(new TriggerType("riding",
+ json -> new RidingTrigger()));
+
+ public static final TriggerType HELD_DURABILITY = TriggerRegistry.register(new TriggerType("held_durability",
+ json -> new HeldDurabilityTrigger(GsonHelper.getAsInt(json, "durability", 0))));
+
+ private TriggerTypes() {
+ }
+
+ public static void bootstrap() {
+ }
+
+ private static List readStrings(JsonObject json, String field) {
+ List values = new ArrayList<>();
+ for (JsonElement element : GsonHelper.getAsJsonArray(json, field, new JsonArray())) {
+ if (element.isJsonPrimitive()) {
+ values.add(element.getAsString());
+ }
+ }
+ return values;
+ }
+
+ private static List readChildren(JsonObject json) {
+ List children = new ArrayList<>();
+ for (JsonElement element : GsonHelper.getAsJsonArray(json, "children", new JsonArray())) {
+ if (element instanceof JsonObject childJson) {
+ Trigger child = TriggerRegistry.fromJson(childJson);
+ if (child != null) {
+ children.add(child);
+ }
+ }
+ }
+ return children;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java
new file mode 100644
index 0000000..a3f0dda
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptAst.java
@@ -0,0 +1,58 @@
+package com.kingjayan.keybinds.dsl;
+
+import java.util.List;
+
+public final class ScriptAst {
+
+ private ScriptAst() {
+ }
+
+ public sealed interface Expr permits AndExpr, OrExpr, NotExpr, CallExpr {
+ int line();
+ }
+
+ public record AndExpr(List children, int line) implements Expr {
+ }
+
+ public record OrExpr(List children, int line) implements Expr {
+ }
+
+ public record NotExpr(Expr child, int line) implements Expr {
+ }
+
+ public record CallExpr(String name, List args, int line) implements Expr {
+ }
+
+ public record Arg(String name, Value value) {
+ }
+
+ public sealed interface Value permits IntValue, FloatValue, StrValue, BoolValue, ListValue {
+ }
+
+ public record IntValue(int value) implements Value {
+ }
+
+ public record FloatValue(float value) implements Value {
+ }
+
+ public record StrValue(String value, boolean bare) implements Value {
+ }
+
+ public record BoolValue(boolean value) implements Value {
+ }
+
+ public record ListValue(List values) implements Value {
+ }
+
+ public sealed interface Stmt permits ActionStmt, IfStmt {
+ }
+
+ public record ActionStmt(CallExpr call) implements Stmt {
+ }
+
+ public record IfStmt(Expr condition, List thenBody, List elseBody, int line) implements Stmt {
+ }
+
+ public record Script(Expr when, List body) {
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java
new file mode 100644
index 0000000..0977e07
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptCompiler.java
@@ -0,0 +1,309 @@
+package com.kingjayan.keybinds.dsl;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.kingjayan.keybinds.core.action.Action;
+import com.kingjayan.keybinds.core.action.ActionRegistry;
+import com.kingjayan.keybinds.core.condition.PlayerConditions;
+import com.kingjayan.keybinds.core.input.KeyNames;
+import com.kingjayan.keybinds.core.trigger.Trigger;
+import com.kingjayan.keybinds.core.trigger.TriggerRegistry;
+import com.kingjayan.keybinds.dsl.ScriptAst.ActionStmt;
+import com.kingjayan.keybinds.dsl.ScriptAst.AndExpr;
+import com.kingjayan.keybinds.dsl.ScriptAst.Arg;
+import com.kingjayan.keybinds.dsl.ScriptAst.BoolValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.CallExpr;
+import com.kingjayan.keybinds.dsl.ScriptAst.Expr;
+import com.kingjayan.keybinds.dsl.ScriptAst.FloatValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.IfStmt;
+import com.kingjayan.keybinds.dsl.ScriptAst.IntValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.ListValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.NotExpr;
+import com.kingjayan.keybinds.dsl.ScriptAst.OrExpr;
+import com.kingjayan.keybinds.dsl.ScriptAst.Script;
+import com.kingjayan.keybinds.dsl.ScriptAst.Stmt;
+import com.kingjayan.keybinds.dsl.ScriptAst.StrValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.Value;
+import com.kingjayan.keybinds.dsl.ScriptProgram.Condition;
+import com.kingjayan.keybinds.dsl.ScriptProgram.Instruction;
+import com.kingjayan.keybinds.ui.editor.EditorSpecs;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class ScriptCompiler {
+
+ private ScriptCompiler() {
+ }
+
+ public static ScriptProgram compile(String source) {
+ Script script = ScriptParser.parse(source);
+ JsonObject triggerJson = triggerJson(script.when());
+ Trigger trigger = TriggerRegistry.fromJson(triggerJson);
+ if (trigger == null) {
+ throw new ScriptException(script.when().line(), "Trigger failed to build");
+ }
+ List instructions = new ArrayList<>();
+ emit(script.body(), instructions);
+ return new ScriptProgram(trigger, instructions);
+ }
+
+ private static void emit(List statements, List out) {
+ for (Stmt stmt : statements) {
+ switch (stmt) {
+ case ActionStmt action -> out.add(Instruction.act(buildAction(action.call())));
+ case IfStmt ifStmt -> {
+ Instruction branch = Instruction.jumpIfFalse(condition(ifStmt.condition()));
+ out.add(branch);
+ emit(ifStmt.thenBody(), out);
+ if (ifStmt.elseBody().isEmpty()) {
+ branch.target = out.size();
+ } else {
+ Instruction skipElse = Instruction.jump();
+ out.add(skipElse);
+ branch.target = out.size();
+ emit(ifStmt.elseBody(), out);
+ skipElse.target = out.size();
+ }
+ }
+ }
+ }
+ }
+
+ private static Action buildAction(CallExpr call) {
+ EditorSpecs.TypeSpec spec = EditorSpecs.actionSpec(call.name());
+ if (spec == null) {
+ throw new ScriptException(call.line(), "Unknown action '" + call.name() + "'");
+ }
+ Action action = ActionRegistry.fromJson(applyArgs(spec, call));
+ if (action == null) {
+ throw new ScriptException(call.line(), "Action '" + call.name() + "' failed to build");
+ }
+ return action;
+ }
+
+ private static JsonObject triggerJson(Expr expr) {
+ switch (expr) {
+ case AndExpr and -> {
+ return group("all_of", and.children());
+ }
+ case OrExpr or -> {
+ return group("any_of", or.children());
+ }
+ case NotExpr not -> {
+ JsonObject json = new JsonObject();
+ json.addProperty("type", "not");
+ json.add("child", triggerJson(not.child()));
+ return json;
+ }
+ case CallExpr call -> {
+ return triggerCallJson(call);
+ }
+ }
+ }
+
+ private static JsonObject group(String type, List children) {
+ JsonObject json = new JsonObject();
+ json.addProperty("type", type);
+ JsonArray array = new JsonArray();
+ for (Expr child : children) {
+ array.add(triggerJson(child));
+ }
+ json.add("children", array);
+ return json;
+ }
+
+ private static JsonObject triggerCallJson(CallExpr call) {
+ String name = resolveTriggerName(call.name());
+ if (name == null) {
+ throw new ScriptException(call.line(), "Unknown trigger '" + call.name() + "'");
+ }
+ if (name.equals("condition") && call.args().isEmpty() && !call.name().equals("condition")) {
+ JsonObject json = new JsonObject();
+ json.addProperty("type", "condition");
+ json.addProperty("condition", call.name());
+ return json;
+ }
+ EditorSpecs.TypeSpec spec = EditorSpecs.triggerSpec(name);
+ return applyArgs(spec, call);
+ }
+
+ private static String resolveTriggerName(String name) {
+ String resolved = switch (name) {
+ case "mouse" -> "mouse_button";
+ case "combo" -> "key_combo";
+ case "all_of", "any_of", "not" -> null;
+ default -> name;
+ };
+ if (resolved != null && EditorSpecs.triggerSpec(resolved) != null) {
+ return resolved;
+ }
+ if (PlayerConditions.ids().contains(name)) {
+ return "condition";
+ }
+ return null;
+ }
+
+ private static Condition condition(Expr expr) {
+ switch (expr) {
+ case AndExpr and -> {
+ return new ScriptProgram.AllCondition(conditions(and.children()));
+ }
+ case OrExpr or -> {
+ return new ScriptProgram.AnyCondition(conditions(or.children()));
+ }
+ case NotExpr not -> {
+ return new ScriptProgram.NotCondition(condition(not.child()));
+ }
+ case CallExpr call -> {
+ return leafCondition(call);
+ }
+ }
+ }
+
+ private static List conditions(List children) {
+ List out = new ArrayList<>(children.size());
+ for (Expr child : children) {
+ out.add(condition(child));
+ }
+ return out;
+ }
+
+ private static Condition leafCondition(CallExpr call) {
+ String name = resolveTriggerName(call.name());
+ if (name == null) {
+ throw new ScriptException(call.line(), "Unknown condition '" + call.name() + "'");
+ }
+ if (name.equals("condition") && !call.name().equals("condition")) {
+ return new ScriptProgram.PlayerCondition(call.name());
+ }
+ EditorSpecs.TypeSpec spec = EditorSpecs.triggerSpec(name);
+ JsonObject json = applyArgs(spec, call);
+ return switch (name) {
+ case "key" -> new ScriptProgram.KeyDownCondition(json.get("key").getAsInt());
+ case "mouse_button" -> new ScriptProgram.MouseDownCondition(json.get("button").getAsInt());
+ case "condition" -> new ScriptProgram.PlayerCondition(json.get("condition").getAsString());
+ case "holding" -> new ScriptProgram.HoldingCondition(
+ EditorSpecs.readStringList(json, "items"));
+ case "key_combo" -> new ScriptProgram.ComboCondition(
+ EditorSpecs.readStringList(json, "keys"));
+ case "riding" -> new ScriptProgram.RidingCondition();
+ case "slot_selected" -> new ScriptProgram.SlotCondition(json.get("slot").getAsInt());
+ case "held_durability" -> new ScriptProgram.DurabilityCondition(json.get("durability").getAsInt());
+ default -> throw new ScriptException(call.line(),
+ "'" + call.name() + "' cannot be used inside if — only state checks are allowed");
+ };
+ }
+
+ private static JsonObject applyArgs(EditorSpecs.TypeSpec spec, CallExpr call) {
+ JsonObject json = EditorSpecs.defaultJson(spec);
+ List params = spec.params();
+ int positional = 0;
+ for (Arg arg : call.args()) {
+ EditorSpecs.ParamSpec param;
+ if (arg.name() != null) {
+ param = params.stream().filter(p -> p.key().equals(arg.name())).findFirst().orElse(null);
+ if (param == null) {
+ throw new ScriptException(call.line(), "'" + spec.id() + "' has no parameter '"
+ + arg.name() + "'" + expected(params));
+ }
+ } else {
+ if (positional < params.size()) {
+ param = params.get(positional++);
+ } else if (!params.isEmpty()
+ && params.getLast().kind() == EditorSpecs.Kind.STRING_LIST) {
+ param = params.getLast();
+ } else {
+ throw new ScriptException(call.line(), "Too many arguments for '" + spec.id() + "'"
+ + expected(params));
+ }
+ }
+ applyValue(json, param, arg.value(), call.line());
+ }
+ return json;
+ }
+
+ private static String expected(List params) {
+ if (params.isEmpty()) {
+ return " — it takes no arguments";
+ }
+ StringBuilder sb = new StringBuilder(" — parameters: ");
+ for (int i = 0; i < params.size(); i++) {
+ sb.append(i > 0 ? ", " : "").append(params.get(i).key());
+ }
+ return sb.toString();
+ }
+
+ private static void applyValue(JsonObject json, EditorSpecs.ParamSpec param, Value value, int line) {
+ switch (param.kind()) {
+ case INT -> json.addProperty(param.key(), intValue(param, value, line));
+ case FLOAT -> json.addProperty(param.key(), floatValue(param, value, line));
+ case KEY -> json.addProperty(param.key(), keyValue(param, value, line, true));
+ case MOUSE -> json.addProperty(param.key(), keyValue(param, value, line, false));
+ case BOOL -> {
+ if (!(value instanceof BoolValue bool)) {
+ throw new ScriptException(line, "'" + param.key() + "' expects true or false");
+ }
+ json.addProperty(param.key(), bool.value());
+ }
+ case STRING -> json.addProperty(param.key(), stringValue(param, value, line));
+ case ENUM -> {
+ String text = stringValue(param, value, line);
+ if (!param.options().contains(text)) {
+ throw new ScriptException(line, "'" + param.key() + "' must be one of "
+ + String.join(", ", param.options()));
+ }
+ json.addProperty(param.key(), text);
+ }
+ case STRING_LIST -> {
+ JsonArray array = json.getAsJsonArray(param.key());
+ switch (value) {
+ case ListValue list -> list.values().forEach(array::add);
+ case StrValue str -> array.add(str.value());
+ case IntValue intValue -> array.add(String.valueOf(intValue.value()));
+ default -> throw new ScriptException(line,
+ "'" + param.key() + "' expects a string or a [list]");
+ }
+ }
+ }
+ }
+
+ private static int intValue(EditorSpecs.ParamSpec param, Value value, int line) {
+ if (value instanceof IntValue intValue) {
+ return intValue.value();
+ }
+ throw new ScriptException(line, "'" + param.key() + "' expects a number");
+ }
+
+ private static float floatValue(EditorSpecs.ParamSpec param, Value value, int line) {
+ if (value instanceof FloatValue floatValue) {
+ return floatValue.value();
+ }
+ if (value instanceof IntValue intValue) {
+ return intValue.value();
+ }
+ throw new ScriptException(line, "'" + param.key() + "' expects a number");
+ }
+
+ private static int keyValue(EditorSpecs.ParamSpec param, Value value, int line, boolean keyboard) {
+ if (value instanceof IntValue intValue) {
+ return intValue.value();
+ }
+ if (value instanceof StrValue str) {
+ Integer code = keyboard ? KeyNames.keyCode(str.value()) : KeyNames.buttonCode(str.value());
+ if (code != null) {
+ return code;
+ }
+ throw new ScriptException(line, "Unknown " + (keyboard ? "key" : "mouse button")
+ + " name '" + str.value() + "'");
+ }
+ throw new ScriptException(line, "'" + param.key() + "' expects a key name or code");
+ }
+
+ private static String stringValue(EditorSpecs.ParamSpec param, Value value, int line) {
+ if (value instanceof StrValue str) {
+ return str.value();
+ }
+ throw new ScriptException(line, "'" + param.key() + "' expects a string");
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java
new file mode 100644
index 0000000..06ec2c8
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptDocs.java
@@ -0,0 +1,109 @@
+package com.kingjayan.keybinds.dsl;
+
+import com.kingjayan.keybinds.core.condition.PlayerConditions;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class ScriptDocs {
+
+ public enum Kind {
+ HEADER, TEXT, TRIGGER, ACTION, CONDITION
+ }
+
+ public record Entry(Kind kind, String name, String signature, String description) {
+
+ static Entry header(String name) {
+ return new Entry(Kind.HEADER, name, "", "");
+ }
+
+ static Entry text(String description) {
+ return new Entry(Kind.TEXT, "", "", description);
+ }
+ }
+
+ private static final List ENTRIES = new ArrayList<>();
+ private static final List COMPLETIONS = new ArrayList<>();
+
+ static {
+ ENTRIES.add(Entry.header("Syntax"));
+ ENTRIES.add(Entry.text("A script starts with one 'when ' line, followed by actions, one per line. '#' starts a comment."));
+ ENTRIES.add(Entry.text("Triggers combine with and / or / not and parentheses: when key(r) and not riding"));
+ ENTRIES.add(Entry.text("Arguments are positional or named: use_item(item=\"minecraft:ender_pearl\", useDelay=3). Lists use [\"a\", \"b\"]."));
+ ENTRIES.add(Entry.text("if { ... } else { ... } branches while the macro runs. Checks are live state: holding, riding, key (held down), mouse, slot_selected, held_durability, and any player condition."));
+ ENTRIES.add(Entry.text("Key names: a-z, 0-9, f1-f25, space, left_shift, ... or a raw GLFW code. Mouse: left, right, middle, button4+."));
+
+ ENTRIES.add(Entry.header("Triggers"));
+ trigger("key", "key(r)", "Fires when the key is pressed. Inside if: true while held.");
+ trigger("key_release", "key_release(r)", "Fires when the key is released.");
+ trigger("mouse", "mouse(left)", "Fires when the mouse button is pressed. Inside if: true while held.");
+ trigger("mouse_release", "mouse_release(left)", "Fires when the mouse button is released.");
+ trigger("combo", "combo(ctrl, c)", "Fires when all listed keys are held together; ctrl, shift, and alt match either side. Inside if: true while all held.");
+ trigger("holding", "holding(\"minecraft:mace\")", "Fires when you start holding one of the items; empty matches anything.");
+ trigger("slot_selected", "slot_selected(0)", "Fires when the hotbar slot (0-8) is selected.");
+ trigger("riding", "riding", "Fires when you mount a vehicle or mob.");
+ trigger("held_durability", "held_durability(10)", "Fires when the held item's remaining durability drops to the value or less.");
+ trigger("condition", "condition(\"low_health\")", "Fires when the player condition becomes true; bare names also work: sneaking.");
+
+ ENTRIES.add(Entry.header("Actions"));
+ action("wait", "wait(2)", "Pauses the macro for that many game ticks.");
+ action("select_slot", "select_slot(0)", "Selects a hotbar slot (0-8).");
+ action("restore_slot", "restore_slot", "Reselects the slot that was active when the macro started.");
+ action("select_item", "select_item(items=[\"minecraft:mace\"], tag=\"\")", "Selects a matching item on the hotbar; aborts if none found.");
+ action("require_item", "require_item(items=[\"minecraft:totem_of_undying\"])", "Aborts the macro unless a matching item is in the inventory.");
+ action("swap_to_item", "swap_to_item(items=[\"minecraft:mace\"], rankEnchant=\"\")", "Swaps a matching inventory item into the hand, remembering the swap.");
+ action("swap_back", "swap_back", "Undoes the most recent swap_to_item.");
+ action("attack", "attack(swing=true)", "Attacks with the held item.");
+ action("use", "use", "Uses the held item once.");
+ action("use_item", "use_item(item=\"minecraft:ender_pearl\", hotbarGate=any, vanillaSwap=false, swapToDelay=2, useDelay=3, swapBackDelay=2)", "Swaps to an item, uses it, and swaps back with configurable tick delays.");
+ action("shield_totem_swap", "shield_totem_swap", "Swaps shield and totem in the offhand.");
+ action("look", "look(north)", "Snaps the camera toward north, south, east, west, up, down, or 90° left/right.");
+ action("turn", "turn(90)", "Sets the camera yaw to the given degrees (0 south, 90 west, 180 north, -90 east).");
+ action("sprint", "sprint(on)", "Turns sprinting on, off, or toggle.");
+ action("sneak", "sneak(toggle)", "Turns sneaking on, off, or toggle.");
+ action("jump", "jump", "Taps the jump key once.");
+ action("double_jump", "double_jump", "Taps jump twice in quick succession.");
+ action("move", "move(forward, 1.2)", "Holds a movement key (forward, back, left, right) long enough to cover roughly that many blocks.");
+ action("random_wait", "random_wait(2, 10)", "Pauses the macro for a random number of ticks between min and max.");
+
+ ENTRIES.add(Entry.header("Player conditions"));
+ for (String id : PlayerConditions.ids()) {
+ Entry entry = new Entry(Kind.CONDITION, id, id, "Player condition, usable as a trigger or if check.");
+ ENTRIES.add(entry);
+ COMPLETIONS.add(entry);
+ }
+ }
+
+ private ScriptDocs() {
+ }
+
+ private static void trigger(String name, String signature, String description) {
+ Entry entry = new Entry(Kind.TRIGGER, name, signature, description);
+ ENTRIES.add(entry);
+ COMPLETIONS.add(entry);
+ }
+
+ private static void action(String name, String signature, String description) {
+ Entry entry = new Entry(Kind.ACTION, name, signature, description);
+ ENTRIES.add(entry);
+ COMPLETIONS.add(entry);
+ }
+
+ public static List entries() {
+ return List.copyOf(ENTRIES);
+ }
+
+ public static List completions(boolean triggerContext, String prefix) {
+ List matches = new ArrayList<>();
+ for (Entry entry : COMPLETIONS) {
+ boolean isTrigger = entry.kind() == Kind.TRIGGER || entry.kind() == Kind.CONDITION;
+ if (isTrigger != triggerContext) {
+ continue;
+ }
+ if (entry.name().startsWith(prefix) && !entry.name().equals(prefix)) {
+ matches.add(entry);
+ }
+ }
+ return matches;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptException.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptException.java
new file mode 100644
index 0000000..fca5b34
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptException.java
@@ -0,0 +1,15 @@
+package com.kingjayan.keybinds.dsl;
+
+public final class ScriptException extends RuntimeException {
+
+ private final int line;
+
+ public ScriptException(int line, String message) {
+ super(message);
+ this.line = line;
+ }
+
+ public int line() {
+ return line;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptExecution.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptExecution.java
new file mode 100644
index 0000000..9cc0c86
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptExecution.java
@@ -0,0 +1,91 @@
+package com.kingjayan.keybinds.dsl;
+
+import com.kingjayan.keybinds.core.action.ActionContext;
+import com.kingjayan.keybinds.core.action.ActionResult;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.dsl.ScriptProgram.Instruction;
+import net.minecraft.client.Minecraft;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public final class ScriptExecution {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger("keybinds");
+ private static final int STEP_LIMIT = 10_000;
+
+ private final Macro macro;
+ private final ScriptProgram program;
+ private final ActionContext context;
+
+ private int pc;
+ private int delay;
+ private boolean aborted;
+
+ public ScriptExecution(Macro macro, ScriptProgram program, ActionContext context) {
+ this.macro = macro;
+ this.program = program;
+ this.context = context;
+ }
+
+ public Macro macro() {
+ return macro;
+ }
+
+ public boolean finished() {
+ return aborted || pc >= program.instructions().size();
+ }
+
+ public void run() {
+ try {
+ runUnsafe();
+ } catch (Exception e) {
+ aborted = true;
+ LOGGER.error("Script macro '{}' failed at instruction {}", macro.name(), pc, e);
+ }
+ }
+
+ private void runUnsafe() {
+ Minecraft client = context.client();
+ int steps = 0;
+ while (!finished()) {
+ if (++steps > STEP_LIMIT) {
+ aborted = true;
+ LOGGER.error("Script macro '{}' exceeded {} steps in one tick, aborting",
+ macro.name(), STEP_LIMIT);
+ return;
+ }
+ Instruction instruction = program.instructions().get(pc);
+ switch (instruction.op) {
+ case Instruction.ACT -> {
+ ActionResult result = instruction.action.execute(context);
+ if (result.aborted()) {
+ aborted = true;
+ return;
+ }
+ pc++;
+ if (result.delayTicks() > 0) {
+ delay = result.delayTicks();
+ return;
+ }
+ }
+ case Instruction.JUMP -> pc = instruction.target;
+ case Instruction.JUMP_IF_FALSE ->
+ pc = instruction.condition.test(client) ? pc + 1 : instruction.target;
+ default -> {
+ aborted = true;
+ return;
+ }
+ }
+ }
+ }
+
+ public void tick() {
+ if (finished() || delay <= 0) {
+ return;
+ }
+ delay--;
+ if (delay <= 0) {
+ run();
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java
new file mode 100644
index 0000000..d003687
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptLexer.java
@@ -0,0 +1,111 @@
+package com.kingjayan.keybinds.dsl;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class ScriptLexer {
+
+ public enum TokenType {
+ IDENT, INT, FLOAT, STRING, LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, COMMA, EQUALS, EOF
+ }
+
+ public record Token(TokenType type, String text, int line) {
+ }
+
+ private ScriptLexer() {
+ }
+
+ public static List tokenize(String source) {
+ List tokens = new ArrayList<>();
+ int line = 1;
+ int i = 0;
+ int length = source.length();
+ while (i < length) {
+ char c = source.charAt(i);
+ if (c == '\n') {
+ line++;
+ i++;
+ } else if (c == ' ' || c == '\t' || c == '\r') {
+ i++;
+ } else if (c == '#') {
+ while (i < length && source.charAt(i) != '\n') {
+ i++;
+ }
+ } else if (c == '(') {
+ tokens.add(new Token(TokenType.LPAREN, "(", line));
+ i++;
+ } else if (c == ')') {
+ tokens.add(new Token(TokenType.RPAREN, ")", line));
+ i++;
+ } else if (c == '{') {
+ tokens.add(new Token(TokenType.LBRACE, "{", line));
+ i++;
+ } else if (c == '}') {
+ tokens.add(new Token(TokenType.RBRACE, "}", line));
+ i++;
+ } else if (c == '[') {
+ tokens.add(new Token(TokenType.LBRACKET, "[", line));
+ i++;
+ } else if (c == ']') {
+ tokens.add(new Token(TokenType.RBRACKET, "]", line));
+ i++;
+ } else if (c == ',') {
+ tokens.add(new Token(TokenType.COMMA, ",", line));
+ i++;
+ } else if (c == '=') {
+ tokens.add(new Token(TokenType.EQUALS, "=", line));
+ i++;
+ } else if (c == '"') {
+ int start = ++i;
+ StringBuilder value = new StringBuilder();
+ boolean closed = false;
+ while (i < length) {
+ char d = source.charAt(i);
+ if (d == '"') {
+ closed = true;
+ i++;
+ break;
+ }
+ if (d == '\n') {
+ break;
+ }
+ value.append(d);
+ i++;
+ }
+ if (!closed) {
+ throw new ScriptException(line, "Unterminated string starting at column " + start);
+ }
+ tokens.add(new Token(TokenType.STRING, value.toString(), line));
+ } else if (Character.isDigit(c) || (c == '-' && i + 1 < length && Character.isDigit(source.charAt(i + 1)))) {
+ int start = i;
+ i++;
+ while (i < length && Character.isDigit(source.charAt(i))) {
+ i++;
+ }
+ boolean decimal = false;
+ if (i + 1 < length && source.charAt(i) == '.' && Character.isDigit(source.charAt(i + 1))) {
+ decimal = true;
+ i++;
+ while (i < length && Character.isDigit(source.charAt(i))) {
+ i++;
+ }
+ }
+ tokens.add(new Token(decimal ? TokenType.FLOAT : TokenType.INT, source.substring(start, i), line));
+ } else if (isWordChar(c)) {
+ int start = i;
+ while (i < length && isWordChar(source.charAt(i))) {
+ i++;
+ }
+ tokens.add(new Token(TokenType.IDENT, source.substring(start, i), line));
+ } else {
+ throw new ScriptException(line, "Unexpected character '" + c + "'");
+ }
+ }
+ tokens.add(new Token(TokenType.EOF, "", line));
+ return tokens;
+ }
+
+ public static boolean isWordChar(char c) {
+ return Character.isLetterOrDigit(c) || c == '_' || c == ':' || c == '.' || c == '/';
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java
new file mode 100644
index 0000000..c6b1298
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptParser.java
@@ -0,0 +1,235 @@
+package com.kingjayan.keybinds.dsl;
+
+import com.kingjayan.keybinds.dsl.ScriptAst.ActionStmt;
+import com.kingjayan.keybinds.dsl.ScriptAst.AndExpr;
+import com.kingjayan.keybinds.dsl.ScriptAst.Arg;
+import com.kingjayan.keybinds.dsl.ScriptAst.BoolValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.CallExpr;
+import com.kingjayan.keybinds.dsl.ScriptAst.Expr;
+import com.kingjayan.keybinds.dsl.ScriptAst.FloatValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.IfStmt;
+import com.kingjayan.keybinds.dsl.ScriptAst.IntValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.ListValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.NotExpr;
+import com.kingjayan.keybinds.dsl.ScriptAst.OrExpr;
+import com.kingjayan.keybinds.dsl.ScriptAst.Script;
+import com.kingjayan.keybinds.dsl.ScriptAst.Stmt;
+import com.kingjayan.keybinds.dsl.ScriptAst.StrValue;
+import com.kingjayan.keybinds.dsl.ScriptAst.Value;
+import com.kingjayan.keybinds.dsl.ScriptLexer.Token;
+import com.kingjayan.keybinds.dsl.ScriptLexer.TokenType;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+public final class ScriptParser {
+
+ private static final Set RESERVED = Set.of("when", "if", "else", "and", "or", "not", "true", "false");
+
+ private final List tokens;
+ private int position;
+
+ private ScriptParser(List tokens) {
+ this.tokens = tokens;
+ }
+
+ public static Script parse(String source) {
+ return new ScriptParser(ScriptLexer.tokenize(source)).parseScript();
+ }
+
+ private Script parseScript() {
+ Token first = peek();
+ if (first.type() != TokenType.IDENT || !first.text().equals("when")) {
+ throw new ScriptException(first.line(), "Script must start with a 'when ' line");
+ }
+ advance();
+ Expr when = parseOr();
+ List body = parseStatements(TokenType.EOF);
+ expect(TokenType.EOF, "end of script");
+ return new Script(when, body);
+ }
+
+ private List parseStatements(TokenType end) {
+ List statements = new ArrayList<>();
+ while (peek().type() != end && peek().type() != TokenType.EOF) {
+ Token token = peek();
+ if (token.type() != TokenType.IDENT) {
+ throw new ScriptException(token.line(), "Expected an action or 'if', found '" + token.text() + "'");
+ }
+ if (token.text().equals("if")) {
+ statements.add(parseIf());
+ } else if (token.text().equals("else")) {
+ throw new ScriptException(token.line(), "'else' without a matching 'if'");
+ } else if (RESERVED.contains(token.text())) {
+ throw new ScriptException(token.line(), "'" + token.text() + "' is not allowed here");
+ } else {
+ statements.add(new ActionStmt(parseCall()));
+ }
+ }
+ return statements;
+ }
+
+ private IfStmt parseIf() {
+ int line = advance().line();
+ Expr condition = parseOr();
+ expect(TokenType.LBRACE, "'{' after if condition");
+ List thenBody = parseStatements(TokenType.RBRACE);
+ expect(TokenType.RBRACE, "'}' to close if block");
+ List elseBody = List.of();
+ if (peek().type() == TokenType.IDENT && peek().text().equals("else")) {
+ advance();
+ expect(TokenType.LBRACE, "'{' after else");
+ elseBody = parseStatements(TokenType.RBRACE);
+ expect(TokenType.RBRACE, "'}' to close else block");
+ }
+ return new IfStmt(condition, thenBody, elseBody, line);
+ }
+
+ private Expr parseOr() {
+ Expr first = parseAnd();
+ if (!matchesWord("or")) {
+ return first;
+ }
+ List children = new ArrayList<>();
+ children.add(first);
+ while (matchesWord("or")) {
+ advance();
+ children.add(parseAnd());
+ }
+ return new OrExpr(children, first.line());
+ }
+
+ private Expr parseAnd() {
+ Expr first = parseUnary();
+ if (!matchesWord("and")) {
+ return first;
+ }
+ List children = new ArrayList<>();
+ children.add(first);
+ while (matchesWord("and")) {
+ advance();
+ children.add(parseUnary());
+ }
+ return new AndExpr(children, first.line());
+ }
+
+ private Expr parseUnary() {
+ Token token = peek();
+ if (token.type() == TokenType.IDENT && token.text().equals("not")) {
+ advance();
+ return new NotExpr(parseUnary(), token.line());
+ }
+ if (token.type() == TokenType.LPAREN) {
+ advance();
+ Expr inner = parseOr();
+ expect(TokenType.RPAREN, "')' to close group");
+ return inner;
+ }
+ if (token.type() == TokenType.IDENT) {
+ if (RESERVED.contains(token.text())) {
+ throw new ScriptException(token.line(), "Expected a trigger, found '" + token.text() + "'");
+ }
+ return parseCall();
+ }
+ throw new ScriptException(token.line(), "Expected a trigger, found '" + token.text() + "'");
+ }
+
+ private CallExpr parseCall() {
+ Token name = expect(TokenType.IDENT, "a name");
+ List args = List.of();
+ if (peek().type() == TokenType.LPAREN) {
+ advance();
+ args = new ArrayList<>();
+ if (peek().type() != TokenType.RPAREN) {
+ args.add(parseArg());
+ while (peek().type() == TokenType.COMMA) {
+ advance();
+ args.add(parseArg());
+ }
+ }
+ expect(TokenType.RPAREN, "')' to close arguments");
+ }
+ return new CallExpr(name.text(), args, name.line());
+ }
+
+ private Arg parseArg() {
+ if (peek().type() == TokenType.IDENT && peekAt(1).type() == TokenType.EQUALS
+ && !RESERVED.contains(peek().text())) {
+ String name = advance().text();
+ advance();
+ return new Arg(name, parseValue());
+ }
+ return new Arg(null, parseValue());
+ }
+
+ private Value parseValue() {
+ Token token = peek();
+ switch (token.type()) {
+ case INT -> {
+ advance();
+ return new IntValue(Integer.parseInt(token.text()));
+ }
+ case FLOAT -> {
+ advance();
+ return new FloatValue(Float.parseFloat(token.text()));
+ }
+ case STRING -> {
+ advance();
+ return new StrValue(token.text(), false);
+ }
+ case IDENT -> {
+ advance();
+ if (token.text().equals("true") || token.text().equals("false")) {
+ return new BoolValue(token.text().equals("true"));
+ }
+ return new StrValue(token.text(), true);
+ }
+ case LBRACKET -> {
+ advance();
+ List values = new ArrayList<>();
+ while (peek().type() != TokenType.RBRACKET) {
+ Token item = peek();
+ if (item.type() != TokenType.STRING && item.type() != TokenType.IDENT) {
+ throw new ScriptException(item.line(), "Lists may only contain names or strings");
+ }
+ advance();
+ values.add(item.text());
+ if (peek().type() == TokenType.COMMA) {
+ advance();
+ }
+ }
+ expect(TokenType.RBRACKET, "']' to close list");
+ return new ListValue(values);
+ }
+ default -> throw new ScriptException(token.line(), "Expected a value, found '" + token.text() + "'");
+ }
+ }
+
+ private boolean matchesWord(String word) {
+ Token token = peek();
+ return token.type() == TokenType.IDENT && token.text().equals(word);
+ }
+
+ private Token peek() {
+ return tokens.get(position);
+ }
+
+ private Token peekAt(int offset) {
+ int index = Math.min(position + offset, tokens.size() - 1);
+ return tokens.get(index);
+ }
+
+ private Token advance() {
+ return tokens.get(position++);
+ }
+
+ private Token expect(TokenType type, String what) {
+ Token token = peek();
+ if (token.type() != type) {
+ throw new ScriptException(token.line(), "Expected " + what + ", found '"
+ + (token.type() == TokenType.EOF ? "end of script" : token.text()) + "'");
+ }
+ return advance();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java
new file mode 100644
index 0000000..e1936e7
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptPrinter.java
@@ -0,0 +1,163 @@
+package com.kingjayan.keybinds.dsl;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.kingjayan.keybinds.core.action.Action;
+import com.kingjayan.keybinds.core.action.ActionRegistry;
+import com.kingjayan.keybinds.core.input.KeyNames;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.trigger.TriggerRegistry;
+import com.kingjayan.keybinds.ui.editor.EditorSpecs;
+import net.minecraft.util.GsonHelper;
+
+import java.util.List;
+import java.util.StringJoiner;
+
+public final class ScriptPrinter {
+
+ private ScriptPrinter() {
+ }
+
+ public static String print(Macro macro) {
+ StringBuilder out = new StringBuilder();
+ out.append("# ").append(macro.name()).append('\n');
+ if (macro.trigger() == null) {
+ out.append("# set a trigger: when key(r), when sneaking and holding(\"minecraft:mace\"), ...\n");
+ out.append("when key()\n");
+ } else {
+ out.append("when ").append(expr(TriggerRegistry.toJson(macro.trigger()), 0)).append('\n');
+ }
+ out.append('\n');
+ if (macro.actions().isEmpty()) {
+ out.append("# add actions below, one per line\n");
+ }
+ for (Action action : macro.actions()) {
+ out.append(statement(ActionRegistry.toJson(action))).append('\n');
+ }
+ return out.toString();
+ }
+
+ private static String expr(JsonObject json, int depth) {
+ String type = GsonHelper.getAsString(json, "type", "");
+ return switch (type) {
+ case "all_of" -> joinChildren(json, " and ", depth, 2);
+ case "any_of" -> joinChildren(json, " or ", depth, 1);
+ case "not" -> {
+ JsonObject child = json.get("child") instanceof JsonObject c ? c : null;
+ yield child == null ? "not key()" : "not " + wrapComposite(child, depth + 1);
+ }
+ case "key" -> "key(" + keyArg(json, "key", true) + ")";
+ case "key_release" -> "key_release(" + keyArg(json, "key", true) + ")";
+ case "mouse_button" -> "mouse(" + keyArg(json, "button", false) + ")";
+ case "mouse_release" -> "mouse_release(" + keyArg(json, "button", false) + ")";
+ case "condition" -> GsonHelper.getAsString(json, "condition", "low_health");
+ case "holding" -> "holding(" + quotedList(json, "items") + ")";
+ case "key_combo" -> "combo(" + String.join(", ", EditorSpecs.readStringList(json, "keys")) + ")";
+ case "slot_selected" -> "slot_selected(" + GsonHelper.getAsInt(json, "slot", 0) + ")";
+ case "riding" -> "riding";
+ case "held_durability" -> "held_durability(" + GsonHelper.getAsInt(json, "durability", 10) + ")";
+ default -> type;
+ };
+ }
+
+ private static String joinChildren(JsonObject json, String separator, int depth, int precedence) {
+ StringJoiner joiner = new StringJoiner(separator);
+ for (JsonElement element : GsonHelper.getAsJsonArray(json, "children", new JsonArray())) {
+ if (element instanceof JsonObject child) {
+ String childType = GsonHelper.getAsString(child, "type", "");
+ boolean parens = (precedence == 2 && childType.equals("any_of"))
+ || (depth > 0 && (childType.equals("any_of") || childType.equals("all_of")));
+ joiner.add(parens ? "(" + expr(child, depth + 1) + ")" : expr(child, depth + 1));
+ }
+ }
+ String joined = joiner.toString();
+ return joined.isEmpty() ? "key()" : joined;
+ }
+
+ private static String wrapComposite(JsonObject child, int depth) {
+ String type = GsonHelper.getAsString(child, "type", "");
+ String text = expr(child, depth);
+ return type.equals("all_of") || type.equals("any_of") ? "(" + text + ")" : text;
+ }
+
+ private static String keyArg(JsonObject json, String field, boolean keyboard) {
+ int code = GsonHelper.getAsInt(json, field, -1);
+ if (code < 0) {
+ return "";
+ }
+ String name = keyboard ? KeyNames.keyName(code) : KeyNames.buttonName(code);
+ return name != null ? name : String.valueOf(code);
+ }
+
+ private static String quotedList(JsonObject json, String field) {
+ StringJoiner joiner = new StringJoiner(", ");
+ for (String item : EditorSpecs.readStringList(json, field)) {
+ joiner.add("\"" + item + "\"");
+ }
+ return joiner.toString();
+ }
+
+ private static String statement(JsonObject json) {
+ String type = GsonHelper.getAsString(json, "type", "");
+ EditorSpecs.TypeSpec spec = EditorSpecs.actionSpec(type);
+ if (spec == null) {
+ return type;
+ }
+ StringJoiner args = new StringJoiner(", ");
+ boolean positional = spec.params().size() == 1;
+ for (EditorSpecs.ParamSpec param : spec.params()) {
+ String value = paramValue(json, param);
+ if (value == null) {
+ continue;
+ }
+ args.add(positional ? value : param.key() + "=" + value);
+ }
+ String joined = args.toString();
+ return joined.isEmpty() ? type : type + "(" + joined + ")";
+ }
+
+ private static String paramValue(JsonObject json, EditorSpecs.ParamSpec param) {
+ switch (param.kind()) {
+ case INT -> {
+ int value = GsonHelper.getAsInt(json, param.key(), Integer.parseInt(param.def()));
+ return value == Integer.parseInt(param.def()) ? null : String.valueOf(value);
+ }
+ case KEY, MOUSE -> {
+ int value = GsonHelper.getAsInt(json, param.key(), -1);
+ if (value < 0) {
+ return null;
+ }
+ String name = param.kind() == EditorSpecs.Kind.KEY
+ ? KeyNames.keyName(value) : KeyNames.buttonName(value);
+ return name != null ? name : String.valueOf(value);
+ }
+ case FLOAT -> {
+ float value = GsonHelper.getAsFloat(json, param.key(), Float.parseFloat(param.def()));
+ return value == Float.parseFloat(param.def()) ? null : EditorSpecs.trimFloat(value);
+ }
+ case BOOL -> {
+ boolean value = GsonHelper.getAsBoolean(json, param.key(), Boolean.parseBoolean(param.def()));
+ return value == Boolean.parseBoolean(param.def()) ? null : String.valueOf(value);
+ }
+ case STRING -> {
+ String value = GsonHelper.getAsString(json, param.key(), param.def());
+ return value.equals(param.def()) || value.isEmpty() ? null : "\"" + value + "\"";
+ }
+ case ENUM -> {
+ String value = GsonHelper.getAsString(json, param.key(), param.def());
+ return value.equals(param.def()) ? null : value;
+ }
+ case STRING_LIST -> {
+ List values = EditorSpecs.readStringList(json, param.key());
+ if (values.isEmpty()) {
+ return null;
+ }
+ StringJoiner joiner = new StringJoiner(", ", "[", "]");
+ values.forEach(v -> joiner.add("\"" + v + "\""));
+ return joiner.toString();
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java
new file mode 100644
index 0000000..1efb8a2
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptProgram.java
@@ -0,0 +1,161 @@
+package com.kingjayan.keybinds.dsl;
+
+import com.kingjayan.keybinds.core.action.Action;
+import com.kingjayan.keybinds.core.condition.PlayerConditions;
+import com.kingjayan.keybinds.core.trigger.KeyComboTrigger;
+import com.kingjayan.keybinds.core.trigger.Trigger;
+import com.kingjayan.keybinds.game.ItemLookup;
+import com.mojang.blaze3d.platform.InputConstants;
+import net.minecraft.client.Minecraft;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.List;
+
+public record ScriptProgram(Trigger trigger, List instructions) {
+
+ public static final class Instruction {
+
+ public static final int ACT = 0;
+ public static final int JUMP = 1;
+ public static final int JUMP_IF_FALSE = 2;
+
+ public final int op;
+ public final Action action;
+ public final Condition condition;
+ public int target;
+
+ private Instruction(int op, Action action, Condition condition, int target) {
+ this.op = op;
+ this.action = action;
+ this.condition = condition;
+ this.target = target;
+ }
+
+ public static Instruction act(Action action) {
+ return new Instruction(ACT, action, null, -1);
+ }
+
+ public static Instruction jump() {
+ return new Instruction(JUMP, null, null, -1);
+ }
+
+ public static Instruction jumpIfFalse(Condition condition) {
+ return new Instruction(JUMP_IF_FALSE, null, condition, -1);
+ }
+ }
+
+ public sealed interface Condition permits AllCondition, AnyCondition, NotCondition, KeyDownCondition,
+ MouseDownCondition, PlayerCondition, HoldingCondition, RidingCondition, SlotCondition,
+ DurabilityCondition, ComboCondition {
+
+ boolean test(Minecraft client);
+ }
+
+ public record AllCondition(List children) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ for (Condition child : children) {
+ if (!child.test(client)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ public record AnyCondition(List children) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ for (Condition child : children) {
+ if (child.test(client)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ public record NotCondition(Condition child) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ return !child.test(client);
+ }
+ }
+
+ public record KeyDownCondition(int key) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ return key >= 0 && client != null && InputConstants.isKeyDown(client.getWindow(), key);
+ }
+ }
+
+ public record MouseDownCondition(int button) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ return button >= 0 && client != null
+ && GLFW.glfwGetMouseButton(client.getWindow().handle(), button) == GLFW.GLFW_PRESS;
+ }
+ }
+
+ public record ComboCondition(List keys) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ return client != null && KeyComboTrigger.allDown(KeyComboTrigger.resolveAll(keys),
+ key -> InputConstants.isKeyDown(client.getWindow(), key));
+ }
+ }
+
+ public record PlayerCondition(String id) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ return client != null && PlayerConditions.active(id, client.player);
+ }
+ }
+
+ public record HoldingCondition(List items) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ if (client == null || client.player == null) {
+ return false;
+ }
+ return items.isEmpty() || ItemLookup.matchesAny(client.player.getMainHandItem(), items);
+ }
+ }
+
+ public record RidingCondition() implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ return client != null && client.player != null && client.player.isPassenger();
+ }
+ }
+
+ public record SlotCondition(int slot) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ return client != null && client.player != null
+ && client.player.getInventory().getSelectedSlot() == slot;
+ }
+ }
+
+ public record DurabilityCondition(int durability) implements Condition {
+
+ @Override
+ public boolean test(Minecraft client) {
+ if (client == null || client.player == null) {
+ return false;
+ }
+ var held = client.player.getMainHandItem();
+ return held.isDamageableItem() && held.getMaxDamage() - held.getDamageValue() <= durability;
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/dsl/ScriptRuntime.java b/src/main/java/com/kingjayan/keybinds/dsl/ScriptRuntime.java
new file mode 100644
index 0000000..a86adae
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/dsl/ScriptRuntime.java
@@ -0,0 +1,89 @@
+package com.kingjayan.keybinds.dsl;
+
+import com.kingjayan.keybinds.core.action.ActionContext;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.schedule.TickScheduler;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.player.LocalPlayer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+public final class ScriptRuntime {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger("keybinds");
+
+ private final List executions = new ArrayList<>();
+ private final Map compiled = new HashMap<>();
+
+ private record CompiledScript(String source, ScriptProgram program, String error, int errorLine) {
+ }
+
+ public void recompile(List macros) {
+ compiled.keySet().removeIf(id -> macros.stream()
+ .noneMatch(macro -> macro.script() != null && macro.id().equals(id)));
+ for (Macro macro : macros) {
+ if (macro.script() == null) {
+ continue;
+ }
+ CompiledScript entry = compiled.get(macro.id());
+ if (entry == null || !entry.source.equals(macro.script())) {
+ entry = compileEntry(macro);
+ compiled.put(macro.id(), entry);
+ }
+ macro.setTrigger(entry.program != null ? entry.program.trigger() : null);
+ }
+ }
+
+ private CompiledScript compileEntry(Macro macro) {
+ try {
+ return new CompiledScript(macro.script(), ScriptCompiler.compile(macro.script()), null, -1);
+ } catch (ScriptException e) {
+ LOGGER.warn("Script macro '{}' failed to compile (line {}): {}",
+ macro.name(), e.line(), e.getMessage());
+ return new CompiledScript(macro.script(), null, e.getMessage(), e.line());
+ } catch (Exception e) {
+ LOGGER.error("Script macro '{}' failed to compile", macro.name(), e);
+ return new CompiledScript(macro.script(), null, e.toString(), -1);
+ }
+ }
+
+ public void tick() {
+ if (executions.isEmpty()) {
+ return;
+ }
+ for (int i = 0; i < executions.size(); i++) {
+ executions.get(i).tick();
+ }
+ executions.removeIf(ScriptExecution::finished);
+ }
+
+ public void start(Macro macro, Minecraft client, TickScheduler scheduler) {
+ CompiledScript entry = compiled.get(macro.id());
+ if (entry == null || entry.program == null || running(macro)) {
+ return;
+ }
+ LocalPlayer player = client.player;
+ int startSlot = player != null ? player.getInventory().getSelectedSlot() : -1;
+ ScriptExecution execution = new ScriptExecution(macro, entry.program,
+ new ActionContext(client, scheduler, startSlot));
+ execution.run();
+ if (!execution.finished()) {
+ executions.add(execution);
+ }
+ }
+
+ private boolean running(Macro macro) {
+ for (ScriptExecution execution : executions) {
+ if (execution.macro() == macro && !execution.finished()) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/game/ItemLookup.java b/src/main/java/com/kingjayan/keybinds/game/ItemLookup.java
new file mode 100644
index 0000000..e95515a
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/game/ItemLookup.java
@@ -0,0 +1,84 @@
+package com.kingjayan.keybinds.game;
+
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.core.registries.Registries;
+import net.minecraft.resources.Identifier;
+import net.minecraft.tags.TagKey;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.item.enchantment.ItemEnchantments;
+
+import java.util.List;
+
+public final class ItemLookup {
+
+ private ItemLookup() {
+ }
+
+ public static Item resolveItem(String id) {
+ if (id == null || id.isBlank()) {
+ return null;
+ }
+ Identifier rl = Identifier.tryParse(id.trim());
+ if (rl == null) {
+ return null;
+ }
+ return BuiltInRegistries.ITEM.getOptional(rl).orElse(null);
+ }
+
+ public static TagKey- resolveTag(String id) {
+ if (id == null || id.isBlank()) {
+ return null;
+ }
+ Identifier rl = Identifier.tryParse(id.trim());
+ return rl == null ? null : TagKey.create(Registries.ITEM, rl);
+ }
+
+ public static boolean matchesAny(ItemStack stack, List ids) {
+ if (ids == null) {
+ return false;
+ }
+ for (String id : ids) {
+ Item item = resolveItem(id);
+ if (item != null && stack.is(item)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static int enchantLevel(ItemStack stack, String enchantId) {
+ if (stack.isEmpty() || enchantId == null || enchantId.isBlank()) {
+ return 0;
+ }
+ Identifier target = Identifier.tryParse(enchantId.trim());
+ if (target == null) {
+ return 0;
+ }
+ ItemEnchantments enchantments = stack.getEnchantments();
+ for (var entry : enchantments.entrySet()) {
+ if (entry.getKey().is(target)) {
+ return entry.getIntValue();
+ }
+ }
+ return 0;
+ }
+
+ public static int findHotbarSlot(Inventory inventory, List items, String tag) {
+ TagKey
- tagKey = resolveTag(tag);
+ for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) {
+ ItemStack stack = inventory.getItem(slot);
+ if (stack.isEmpty()) {
+ continue;
+ }
+ if (tagKey != null && stack.is(tagKey)) {
+ return slot;
+ }
+ if (matchesAny(stack, items)) {
+ return slot;
+ }
+ }
+ return -1;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/game/PlayerActions.java b/src/main/java/com/kingjayan/keybinds/game/PlayerActions.java
new file mode 100644
index 0000000..a35b293
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/game/PlayerActions.java
@@ -0,0 +1,166 @@
+package com.kingjayan.keybinds.game;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.multiplayer.MultiPlayerGameMode;
+import net.minecraft.client.player.LocalPlayer;
+import net.minecraft.network.protocol.game.ServerboundSetCarriedItemPacket;
+import net.minecraft.world.InteractionHand;
+import net.minecraft.world.InteractionResult;
+import net.minecraft.world.entity.Entity;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.inventory.ContainerInput;
+import net.minecraft.world.inventory.InventoryMenu;
+import net.minecraft.world.item.Item;
+import net.minecraft.world.phys.BlockHitResult;
+import net.minecraft.world.phys.HitResult;
+
+public final class PlayerActions {
+
+ public static final int MAIN_INVENTORY_END = 36;
+ public static final int OFFHAND_SWAP_BUTTON = 40;
+
+ private PlayerActions() {
+ }
+
+ public static void selectHotbarSlot(Minecraft client, Inventory inventory, int slot) {
+ inventory.setSelectedSlot(slot);
+ if (client.getConnection() != null) {
+ client.getConnection().send(new ServerboundSetCarriedItemPacket(slot));
+ }
+ }
+
+ public static void selectHotbarSlotIfValid(Minecraft client, int slot) {
+ LocalPlayer player = client.player;
+ if (player == null || slot < 0 || slot >= Inventory.SELECTION_SIZE) {
+ return;
+ }
+ selectHotbarSlot(client, player.getInventory(), slot);
+ }
+
+ public static void useHand(Minecraft client, InteractionHand hand) {
+ LocalPlayer player = client.player;
+ MultiPlayerGameMode gameMode = client.gameMode;
+ if (player == null || gameMode == null) {
+ return;
+ }
+
+ HitResult hit = client.hitResult;
+ if (hit instanceof BlockHitResult blockHit && hit.getType() == HitResult.Type.BLOCK) {
+ InteractionResult result = gameMode.useItemOn(player, hand, blockHit);
+ if (result.consumesAction()) {
+ player.swing(hand);
+ return;
+ }
+ }
+
+ InteractionResult result = gameMode.useItem(player, hand);
+ if (result.consumesAction()) {
+ player.swing(hand);
+ }
+ }
+
+ public static void useItemInstant(Minecraft client, Item item) {
+ LocalPlayer player = client.player;
+ MultiPlayerGameMode gameMode = client.gameMode;
+ if (player == null || gameMode == null) {
+ return;
+ }
+
+ for (InteractionHand hand : InteractionHand.values()) {
+ if (player.getItemInHand(hand).is(item)) {
+ useHand(client, hand);
+ return;
+ }
+ }
+
+ Inventory inventory = player.getInventory();
+
+ for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) {
+ if (inventory.getItem(slot).is(item)) {
+ int previousSlot = inventory.getSelectedSlot();
+ selectHotbarSlot(client, inventory, slot);
+ useHand(client, InteractionHand.MAIN_HAND);
+ selectHotbarSlot(client, inventory, previousSlot);
+ return;
+ }
+ }
+
+ if (player.containerMenu == player.inventoryMenu) {
+ int hotbarSlot = inventory.getSelectedSlot();
+ int containerId = player.inventoryMenu.containerId;
+ for (int slot = Inventory.SELECTION_SIZE; slot < MAIN_INVENTORY_END; slot++) {
+ if (inventory.getItem(slot).is(item)) {
+ gameMode.handleContainerInput(containerId, slot, hotbarSlot, ContainerInput.SWAP, player);
+ useHand(client, InteractionHand.MAIN_HAND);
+ gameMode.handleContainerInput(containerId, slot, hotbarSlot, ContainerInput.SWAP, player);
+ return;
+ }
+ }
+ }
+ }
+
+ public static void attackCrosshairTarget(Minecraft client) {
+ LocalPlayer player = client.player;
+ MultiPlayerGameMode gameMode = client.gameMode;
+ if (player == null || gameMode == null) {
+ return;
+ }
+ Entity target = client.crosshairPickEntity;
+ if (target != null) {
+ gameMode.attack(player, target);
+ player.swing(InteractionHand.MAIN_HAND);
+ }
+ }
+
+ public static void attackCrosshairOrSwing(Minecraft client) {
+ LocalPlayer player = client.player;
+ MultiPlayerGameMode gameMode = client.gameMode;
+ if (player == null || gameMode == null) {
+ return;
+ }
+ Entity target = client.crosshairPickEntity;
+ if (target != null) {
+ gameMode.attack(player, target);
+ }
+ player.swing(InteractionHand.MAIN_HAND);
+ }
+
+ public static void swapBuriedWithHeld(Minecraft client, int containerId, int buriedSlot, int hotbarSlot) {
+ LocalPlayer player = client.player;
+ MultiPlayerGameMode gameMode = client.gameMode;
+ if (player == null || gameMode == null || player.containerMenu != player.inventoryMenu) {
+ return;
+ }
+ gameMode.handleContainerInput(containerId, buriedSlot, hotbarSlot, ContainerInput.SWAP, player);
+ }
+
+ public static void swapWithOffhand(MultiPlayerGameMode gameMode, int containerId,
+ LocalPlayer player, int containerIndex) {
+ gameMode.handleContainerInput(
+ containerId, menuSlotOf(containerIndex), OFFHAND_SWAP_BUTTON, ContainerInput.SWAP, player);
+ }
+
+ public static int menuSlotOf(int containerIndex) {
+ return containerIndex < Inventory.SELECTION_SIZE
+ ? InventoryMenu.USE_ROW_SLOT_START + containerIndex
+ : containerIndex;
+ }
+
+ public static int findHotbarSlot(Inventory inventory, Item item) {
+ for (int slot = 0; slot < Inventory.SELECTION_SIZE; slot++) {
+ if (inventory.getItem(slot).is(item)) {
+ return slot;
+ }
+ }
+ return -1;
+ }
+
+ public static int findItem(Inventory inventory, Item item) {
+ for (int slot = 0; slot < MAIN_INVENTORY_END; slot++) {
+ if (inventory.getItem(slot).is(item)) {
+ return slot;
+ }
+ }
+ return -1;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/persist/MacroStorage.java b/src/main/java/com/kingjayan/keybinds/persist/MacroStorage.java
new file mode 100644
index 0000000..6446867
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/persist/MacroStorage.java
@@ -0,0 +1,60 @@
+package com.kingjayan.keybinds.persist;
+
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.serialize.MacroCodec;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import net.fabricmc.loader.api.FabricLoader;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.List;
+
+public final class MacroStorage {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger("keybinds");
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+ private MacroStorage() {
+ }
+
+ public static Path path() {
+ return FabricLoader.getInstance().getConfigDir().resolve("macros.json");
+ }
+
+ public static List load() {
+ Path path = path();
+ if (!Files.exists(path)) {
+ return List.of();
+ }
+ try {
+ String content = Files.readString(path);
+ if (JsonParser.parseString(content) instanceof JsonObject root) {
+ return MacroCodec.read(root);
+ }
+ LOGGER.warn("macros.json root is not an object, not loading");
+ return List.of();
+ } catch (Exception e) {
+ LOGGER.error("Failed to load macros.json", e);
+ return List.of();
+ }
+ }
+
+ public static void save(List macros) {
+ Path path = path();
+ try {
+ Files.createDirectories(path.getParent());
+ Path temp = path.resolveSibling("macros.json.tmp");
+ Files.writeString(temp, GSON.toJson(MacroCodec.write(macros)));
+ Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException e) {
+ LOGGER.error("Failed to save macros.json", e);
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java b/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java
new file mode 100644
index 0000000..b7fe80b
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/serialize/MacroCodec.java
@@ -0,0 +1,107 @@
+package com.kingjayan.keybinds.serialize;
+
+import com.kingjayan.keybinds.core.action.Action;
+import com.kingjayan.keybinds.core.action.ActionRegistry;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.trigger.Trigger;
+import com.kingjayan.keybinds.core.trigger.TriggerRegistry;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import net.minecraft.util.GsonHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+
+public final class MacroCodec {
+
+ public static final int FORMAT_VERSION = 1;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger("keybinds");
+
+ private MacroCodec() {
+ }
+
+ public static JsonObject write(List macros) {
+ JsonObject root = new JsonObject();
+ root.addProperty("formatVersion", FORMAT_VERSION);
+ JsonArray array = new JsonArray();
+ for (Macro macro : macros) {
+ array.add(writeMacro(macro));
+ }
+ root.add("macros", array);
+ return root;
+ }
+
+ public static List read(JsonObject root) {
+ int version = GsonHelper.getAsInt(root, "formatVersion", FORMAT_VERSION);
+ if (version > FORMAT_VERSION) {
+ LOGGER.warn("macros.json format version {} is newer than supported version {}, not loading",
+ version, FORMAT_VERSION);
+ return List.of();
+ }
+ List macros = new ArrayList<>();
+ for (JsonElement element : GsonHelper.getAsJsonArray(root, "macros", new JsonArray())) {
+ if (element instanceof JsonObject json) {
+ Macro macro = readMacro(json);
+ if (macro != null) {
+ macros.add(macro);
+ }
+ }
+ }
+ return macros;
+ }
+
+ public static JsonObject writeMacro(Macro macro) {
+ JsonObject json = new JsonObject();
+ json.addProperty("id", macro.id().toString());
+ json.addProperty("name", macro.name());
+ json.addProperty("enabled", macro.enabled());
+ if (macro.script() != null) {
+ json.addProperty("script", macro.script());
+ return json;
+ }
+ if (macro.trigger() != null) {
+ json.add("trigger", TriggerRegistry.toJson(macro.trigger()));
+ }
+ JsonArray actions = new JsonArray();
+ for (Action action : macro.actions()) {
+ actions.add(ActionRegistry.toJson(action));
+ }
+ json.add("actions", actions);
+ return json;
+ }
+
+ public static Macro readMacro(JsonObject json) {
+ UUID id = parseId(GsonHelper.getAsString(json, "id", ""));
+ String name = GsonHelper.getAsString(json, "name", "Unnamed");
+ boolean enabled = GsonHelper.getAsBoolean(json, "enabled", true);
+ Trigger trigger = json.get("trigger") instanceof JsonObject triggerJson
+ ? TriggerRegistry.fromJson(triggerJson) : null;
+ List actions = new ArrayList<>();
+ for (JsonElement element : GsonHelper.getAsJsonArray(json, "actions", new JsonArray())) {
+ if (element instanceof JsonObject actionJson) {
+ Action action = ActionRegistry.fromJson(actionJson);
+ if (action != null) {
+ actions.add(action);
+ }
+ }
+ }
+ Macro macro = new Macro(id, name, enabled, trigger, actions);
+ if (json.get("script") != null && json.get("script").isJsonPrimitive()) {
+ macro.setScript(json.get("script").getAsString());
+ }
+ return macro;
+ }
+
+ private static UUID parseId(String raw) {
+ try {
+ return UUID.fromString(raw);
+ } catch (IllegalArgumentException e) {
+ return UUID.randomUUID();
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java b/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java
new file mode 100644
index 0000000..7cb77c6
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/editor/CodeEditor.java
@@ -0,0 +1,541 @@
+package com.kingjayan.keybinds.ui.editor;
+
+import com.kingjayan.keybinds.core.condition.PlayerConditions;
+import com.kingjayan.keybinds.dsl.ScriptDocs;
+import com.kingjayan.keybinds.dsl.ScriptLexer;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.input.CharacterEvent;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+
+public class CodeEditor extends AbstractWidget {
+
+ private static final int LINE_HEIGHT = 10;
+ private static final int GUTTER = 24;
+ private static final int PADDING = 4;
+ private static final int MAX_SUGGESTIONS = 6;
+
+ private static final int BACKGROUND = 0xF0101018;
+ private static final int BORDER = 0xFF404060;
+ private static final int GUTTER_COLOR = 0xFF606070;
+ private static final int ERROR_TINT = 0x50FF2020;
+ private static final int CURSOR_COLOR = 0xFFFFFFFF;
+ private static final int POPUP_BG = 0xF8181828;
+ private static final int POPUP_BORDER = 0xFF5000A0;
+ private static final int POPUP_HOVER = 0x40FFFFFF;
+
+ private static final int COLOR_DEFAULT = 0xFFF0F0F0;
+ private static final int COLOR_KEYWORD = 0xFFFFAA55;
+ private static final int COLOR_TRIGGER = 0xFF66D9EF;
+ private static final int COLOR_ACTION = 0xFFA6E22E;
+ private static final int COLOR_STRING = 0xFFE6DB74;
+ private static final int COLOR_NUMBER = 0xFFAE81FF;
+ private static final int COLOR_COMMENT = 0xFF75715E;
+ private static final int COLOR_PUNCT = 0xFF9090A0;
+
+ private static final Set KEYWORDS = Set.of("when", "if", "else", "and", "or", "not", "true", "false");
+ private static final Set TRIGGER_WORDS = new HashSet<>();
+ private static final Set ACTION_WORDS = new HashSet<>();
+
+ static {
+ for (EditorSpecs.TypeSpec spec : EditorSpecs.triggerTypes()) {
+ TRIGGER_WORDS.add(spec.id());
+ }
+ TRIGGER_WORDS.add("mouse");
+ TRIGGER_WORDS.add("combo");
+ TRIGGER_WORDS.addAll(PlayerConditions.ids());
+ for (EditorSpecs.TypeSpec spec : EditorSpecs.actionTypes()) {
+ ACTION_WORDS.add(spec.id());
+ }
+ }
+
+ private final Font font;
+ private final Consumer onChange;
+ private final List lines = new ArrayList<>();
+
+ private int cursorLine;
+ private int cursorCol;
+ private int scrollLine;
+ private int scrollX;
+ private int errorLine = -1;
+ private List suggestions = List.of();
+ private int suggestionIndex;
+
+ public CodeEditor(int x, int y, int width, int height, Font font, Consumer onChange) {
+ super(x, y, width, height, Component.empty());
+ this.font = font;
+ this.onChange = onChange;
+ lines.add(new StringBuilder());
+ }
+
+ public void setText(String text) {
+ lines.clear();
+ for (String line : text.split("\n", -1)) {
+ lines.add(new StringBuilder(line));
+ }
+ if (lines.isEmpty()) {
+ lines.add(new StringBuilder());
+ }
+ cursorLine = 0;
+ cursorCol = 0;
+ scrollLine = 0;
+ scrollX = 0;
+ suggestions = List.of();
+ }
+
+ public String text() {
+ StringBuilder out = new StringBuilder();
+ for (int i = 0; i < lines.size(); i++) {
+ if (i > 0) {
+ out.append('\n');
+ }
+ out.append(lines.get(i));
+ }
+ return out.toString();
+ }
+
+ public void setErrorLine(int line) {
+ errorLine = line;
+ }
+
+ private void changed() {
+ onChange.accept(text());
+ updateSuggestions();
+ ensureCursorVisible();
+ }
+
+ private int visibleLines() {
+ return (height - PADDING * 2) / LINE_HEIGHT;
+ }
+
+ private void ensureCursorVisible() {
+ if (cursorLine < scrollLine) {
+ scrollLine = cursorLine;
+ }
+ if (cursorLine >= scrollLine + visibleLines()) {
+ scrollLine = cursorLine - visibleLines() + 1;
+ }
+ int textWidth = width - GUTTER - PADDING * 2;
+ int cursorX = font.width(lines.get(cursorLine).substring(0, cursorCol));
+ if (cursorX < scrollX) {
+ scrollX = Math.max(0, cursorX - 20);
+ }
+ if (cursorX > scrollX + textWidth - 10) {
+ scrollX = cursorX - textWidth + 30;
+ }
+ }
+
+ private void updateSuggestions() {
+ String prefix = currentWord();
+ if (prefix.isEmpty()) {
+ suggestions = List.of();
+ return;
+ }
+ suggestions = ScriptDocs.completions(inTriggerContext(), prefix);
+ if (suggestions.size() > MAX_SUGGESTIONS) {
+ suggestions = suggestions.subList(0, MAX_SUGGESTIONS);
+ }
+ suggestionIndex = 0;
+ }
+
+ private String currentWord() {
+ StringBuilder line = lines.get(cursorLine);
+ int start = cursorCol;
+ while (start > 0 && isNameChar(line.charAt(start - 1))) {
+ start--;
+ }
+ String word = line.substring(start, cursorCol);
+ return word.chars().allMatch(c -> Character.isLetter(c) || c == '_') ? word : "";
+ }
+
+ private static boolean isNameChar(char c) {
+ return ScriptLexer.isWordChar(c);
+ }
+
+ private boolean inTriggerContext() {
+ String before = textBeforeWord();
+ int i = before.length() - 1;
+ while (i >= 0 && Character.isWhitespace(before.charAt(i))) {
+ i--;
+ }
+ if (i < 0) {
+ return false;
+ }
+ char c = before.charAt(i);
+ if (c == '(') {
+ return true;
+ }
+ if (!isNameChar(c)) {
+ return false;
+ }
+ int end = i + 1;
+ while (i >= 0 && isNameChar(before.charAt(i))) {
+ i--;
+ }
+ String word = before.substring(i + 1, end);
+ return word.equals("when") || word.equals("if") || word.equals("and")
+ || word.equals("or") || word.equals("not");
+ }
+
+ private String textBeforeWord() {
+ StringBuilder line = lines.get(cursorLine);
+ int start = cursorCol;
+ while (start > 0 && isNameChar(line.charAt(start - 1))) {
+ start--;
+ }
+ StringBuilder out = new StringBuilder();
+ for (int i = 0; i < cursorLine; i++) {
+ out.append(lines.get(i)).append('\n');
+ }
+ out.append(line, 0, start);
+ return out.toString();
+ }
+
+ private void acceptSuggestion() {
+ ScriptDocs.Entry entry = suggestions.get(suggestionIndex);
+ String word = currentWord();
+ StringBuilder line = lines.get(cursorLine);
+ String remainder = entry.name().substring(word.length());
+ line.insert(cursorCol, remainder);
+ cursorCol += remainder.length();
+ suggestions = List.of();
+ onChange.accept(text());
+ ensureCursorVisible();
+ }
+
+ @Override
+ public boolean charTyped(CharacterEvent event) {
+ if (!isFocused()) {
+ return false;
+ }
+ if (event.codepoint() < 32 || event.codepoint() == 127) {
+ return false;
+ }
+ String chars = event.codepointAsString();
+ StringBuilder line = lines.get(cursorLine);
+ line.insert(cursorCol, chars);
+ cursorCol += chars.length();
+ changed();
+ return true;
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent event) {
+ if (!isFocused()) {
+ return false;
+ }
+ if (!suggestions.isEmpty()) {
+ switch (event.key()) {
+ case GLFW.GLFW_KEY_DOWN -> {
+ suggestionIndex = (suggestionIndex + 1) % suggestions.size();
+ return true;
+ }
+ case GLFW.GLFW_KEY_UP -> {
+ suggestionIndex = (suggestionIndex + suggestions.size() - 1) % suggestions.size();
+ return true;
+ }
+ case GLFW.GLFW_KEY_TAB, GLFW.GLFW_KEY_ENTER -> {
+ acceptSuggestion();
+ return true;
+ }
+ case GLFW.GLFW_KEY_ESCAPE -> {
+ suggestions = List.of();
+ return true;
+ }
+ default -> {
+ }
+ }
+ }
+ if (event.hasControlDown()) {
+ switch (event.key()) {
+ case GLFW.GLFW_KEY_V -> {
+ paste();
+ return true;
+ }
+ case GLFW.GLFW_KEY_C -> {
+ Minecraft.getInstance().keyboardHandler.setClipboard(text());
+ return true;
+ }
+ default -> {
+ }
+ }
+ }
+ switch (event.key()) {
+ case GLFW.GLFW_KEY_ENTER, GLFW.GLFW_KEY_KP_ENTER -> {
+ StringBuilder line = lines.get(cursorLine);
+ String tail = line.substring(cursorCol);
+ line.setLength(cursorCol);
+ String indent = leadingSpaces(line.toString());
+ lines.add(cursorLine + 1, new StringBuilder(indent + tail));
+ cursorLine++;
+ cursorCol = indent.length();
+ changed();
+ return true;
+ }
+ case GLFW.GLFW_KEY_BACKSPACE -> {
+ if (cursorCol > 0) {
+ lines.get(cursorLine).deleteCharAt(--cursorCol);
+ } else if (cursorLine > 0) {
+ StringBuilder previous = lines.get(cursorLine - 1);
+ cursorCol = previous.length();
+ previous.append(lines.remove(cursorLine));
+ cursorLine--;
+ }
+ changed();
+ return true;
+ }
+ case GLFW.GLFW_KEY_DELETE -> {
+ StringBuilder line = lines.get(cursorLine);
+ if (cursorCol < line.length()) {
+ line.deleteCharAt(cursorCol);
+ } else if (cursorLine < lines.size() - 1) {
+ line.append(lines.remove(cursorLine + 1));
+ }
+ changed();
+ return true;
+ }
+ case GLFW.GLFW_KEY_TAB -> {
+ lines.get(cursorLine).insert(cursorCol, " ");
+ cursorCol += 2;
+ changed();
+ return true;
+ }
+ case GLFW.GLFW_KEY_LEFT -> {
+ if (cursorCol > 0) {
+ cursorCol--;
+ } else if (cursorLine > 0) {
+ cursorLine--;
+ cursorCol = lines.get(cursorLine).length();
+ }
+ suggestions = List.of();
+ ensureCursorVisible();
+ return true;
+ }
+ case GLFW.GLFW_KEY_RIGHT -> {
+ if (cursorCol < lines.get(cursorLine).length()) {
+ cursorCol++;
+ } else if (cursorLine < lines.size() - 1) {
+ cursorLine++;
+ cursorCol = 0;
+ }
+ suggestions = List.of();
+ ensureCursorVisible();
+ return true;
+ }
+ case GLFW.GLFW_KEY_UP -> {
+ moveLine(-1);
+ return true;
+ }
+ case GLFW.GLFW_KEY_DOWN -> {
+ moveLine(1);
+ return true;
+ }
+ case GLFW.GLFW_KEY_HOME -> {
+ cursorCol = 0;
+ suggestions = List.of();
+ ensureCursorVisible();
+ return true;
+ }
+ case GLFW.GLFW_KEY_END -> {
+ cursorCol = lines.get(cursorLine).length();
+ suggestions = List.of();
+ ensureCursorVisible();
+ return true;
+ }
+ default -> {
+ return false;
+ }
+ }
+ }
+
+ private void moveLine(int delta) {
+ int target = cursorLine + delta;
+ if (target >= 0 && target < lines.size()) {
+ cursorLine = target;
+ cursorCol = Math.min(cursorCol, lines.get(cursorLine).length());
+ }
+ suggestions = List.of();
+ ensureCursorVisible();
+ }
+
+ private static String leadingSpaces(String line) {
+ int i = 0;
+ while (i < line.length() && line.charAt(i) == ' ') {
+ i++;
+ }
+ return line.substring(0, i);
+ }
+
+ private void paste() {
+ String clipboard = Minecraft.getInstance().keyboardHandler.getClipboard();
+ if (clipboard.isEmpty()) {
+ return;
+ }
+ String[] parts = clipboard.replace("\r\n", "\n").replace('\r', '\n').split("\n", -1);
+ StringBuilder line = lines.get(cursorLine);
+ String tail = line.substring(cursorCol);
+ line.setLength(cursorCol);
+ line.append(parts[0]);
+ for (int i = 1; i < parts.length; i++) {
+ lines.add(cursorLine + i, new StringBuilder(parts[i]));
+ }
+ cursorLine += parts.length - 1;
+ cursorCol = lines.get(cursorLine).length();
+ lines.get(cursorLine).append(tail);
+ changed();
+ }
+
+ @Override
+ public void onClick(MouseButtonEvent event, boolean doubleClick) {
+ int line = scrollLine + ((int) event.y() - getY() - PADDING) / LINE_HEIGHT;
+ cursorLine = Math.clamp(line, 0, lines.size() - 1);
+ int targetX = (int) event.x() - (getX() + GUTTER + PADDING) + scrollX;
+ cursorCol = colAt(lines.get(cursorLine).toString(), targetX);
+ suggestions = List.of();
+ }
+
+ private int colAt(String line, int targetX) {
+ if (targetX <= 0) {
+ return 0;
+ }
+ int x = 0;
+ for (int i = 0; i < line.length(); i++) {
+ int w = font.width(String.valueOf(line.charAt(i)));
+ if (x + w / 2 >= targetX) {
+ return i;
+ }
+ x += w;
+ }
+ return line.length();
+ }
+
+ @Override
+ public boolean mouseScrolled(double x, double y, double scrollXAmount, double scrollYAmount) {
+ if (!visible || !isMouseOver(x, y)) {
+ return false;
+ }
+ scrollLine = Math.clamp(scrollLine - (int) Math.signum(scrollYAmount) * 3, 0,
+ Math.max(0, lines.size() - visibleLines()));
+ return true;
+ }
+
+ @Override
+ protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
+ int left = getX();
+ int top = getY();
+ graphics.fill(left - 1, top - 1, left + width + 1, top + height + 1, BORDER);
+ graphics.fill(left, top, left + width, top + height, BACKGROUND);
+ graphics.enableScissor(left, top, left + width, top + height);
+ int last = Math.min(lines.size(), scrollLine + visibleLines());
+ for (int i = scrollLine; i < last; i++) {
+ int y = top + PADDING + (i - scrollLine) * LINE_HEIGHT;
+ if (i == errorLine) {
+ graphics.fill(left + 1, y - 1, left + width - 1, y + LINE_HEIGHT - 1, ERROR_TINT);
+ }
+ graphics.text(font, Component.literal(String.valueOf(i + 1)), left + 3, y, GUTTER_COLOR);
+ drawLine(graphics, lines.get(i).toString(), left + GUTTER + PADDING - scrollX, y);
+ }
+ if (isFocused() && cursorLine >= scrollLine && cursorLine < last
+ && (System.currentTimeMillis() / 500) % 2 == 0) {
+ int cx = left + GUTTER + PADDING - scrollX
+ + font.width(lines.get(cursorLine).substring(0, cursorCol));
+ int cy = top + PADDING + (cursorLine - scrollLine) * LINE_HEIGHT;
+ graphics.fill(cx, cy - 1, cx + 1, cy + LINE_HEIGHT - 2, CURSOR_COLOR);
+ }
+ graphics.disableScissor();
+ if (isFocused() && !suggestions.isEmpty()) {
+ drawSuggestions(graphics, left, top);
+ }
+ }
+
+ private void drawLine(GuiGraphicsExtractor graphics, String line, int x, int y) {
+ int i = 0;
+ while (i < line.length()) {
+ char c = line.charAt(i);
+ int start = i;
+ int color;
+ if (c == '#') {
+ i = line.length();
+ color = COLOR_COMMENT;
+ } else if (c == '"') {
+ i++;
+ while (i < line.length() && line.charAt(i) != '"') {
+ i++;
+ }
+ if (i < line.length()) {
+ i++;
+ }
+ color = COLOR_STRING;
+ } else if (Character.isDigit(c) || (c == '-' && i + 1 < line.length()
+ && Character.isDigit(line.charAt(i + 1)))) {
+ i++;
+ while (i < line.length() && Character.isDigit(line.charAt(i))) {
+ i++;
+ }
+ color = COLOR_NUMBER;
+ } else if (isNameChar(c)) {
+ while (i < line.length() && isNameChar(line.charAt(i))) {
+ i++;
+ }
+ String word = line.substring(start, i);
+ if (KEYWORDS.contains(word)) {
+ color = COLOR_KEYWORD;
+ } else if (TRIGGER_WORDS.contains(word)) {
+ color = COLOR_TRIGGER;
+ } else if (ACTION_WORDS.contains(word)) {
+ color = COLOR_ACTION;
+ } else {
+ color = COLOR_DEFAULT;
+ }
+ } else if (c == ' ' || c == '\t') {
+ i++;
+ x += font.width(line.substring(start, i));
+ continue;
+ } else {
+ i++;
+ color = COLOR_PUNCT;
+ }
+ String segment = line.substring(start, i);
+ graphics.text(font, Component.literal(segment), x, y, color);
+ x += font.width(segment);
+ }
+ }
+
+ private void drawSuggestions(GuiGraphicsExtractor graphics, int left, int top) {
+ int popupWidth = 0;
+ for (ScriptDocs.Entry entry : suggestions) {
+ popupWidth = Math.max(popupWidth, font.width(entry.name()) + 8);
+ }
+ int cx = left + GUTTER + PADDING - scrollX
+ + font.width(lines.get(cursorLine).substring(0, cursorCol)) - font.width(currentWord());
+ int cy = top + PADDING + (cursorLine - scrollLine + 1) * LINE_HEIGHT + 1;
+ int popupHeight = suggestions.size() * LINE_HEIGHT + 4;
+ graphics.fill(cx - 1, cy - 1, cx + popupWidth + 1, cy + popupHeight + 1, POPUP_BORDER);
+ graphics.fill(cx, cy, cx + popupWidth, cy + popupHeight, POPUP_BG);
+ for (int i = 0; i < suggestions.size(); i++) {
+ int rowY = cy + 2 + i * LINE_HEIGHT;
+ if (i == suggestionIndex) {
+ graphics.fill(cx, rowY - 1, cx + popupWidth, rowY + LINE_HEIGHT - 1, POPUP_HOVER);
+ }
+ ScriptDocs.Entry entry = suggestions.get(i);
+ int color = entry.kind() == ScriptDocs.Kind.ACTION ? COLOR_ACTION : COLOR_TRIGGER;
+ graphics.text(font, Component.literal(entry.name()), cx + 4, rowY, color);
+ }
+ }
+
+ @Override
+ protected void updateWidgetNarration(NarrationElementOutput output) {
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java b/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java
new file mode 100644
index 0000000..c2f4ae0
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/editor/EditorSpecs.java
@@ -0,0 +1,258 @@
+package com.kingjayan.keybinds.ui.editor;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.kingjayan.keybinds.core.condition.PlayerConditions;
+import com.mojang.blaze3d.platform.InputConstants;
+import net.minecraft.util.GsonHelper;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.StringJoiner;
+
+public final class EditorSpecs {
+
+ public enum Kind {
+ INT, FLOAT, BOOL, STRING, STRING_LIST, ENUM, KEY, MOUSE
+ }
+
+ public record ParamSpec(String key, String label, Kind kind, String def, List options) {
+
+ static ParamSpec of(String key, String label, Kind kind, String def) {
+ return new ParamSpec(key, label, kind, def, List.of());
+ }
+
+ static ParamSpec choice(String key, String label, String def, List options) {
+ return new ParamSpec(key, label, Kind.ENUM, def, options);
+ }
+ }
+
+ public enum Nesting {
+ NONE, ONE, MANY
+ }
+
+ public record TypeSpec(String id, String label, Nesting nesting, List params) {
+ }
+
+ private static final Map ACTIONS = new LinkedHashMap<>();
+ private static final Map TRIGGERS = new LinkedHashMap<>();
+
+ static {
+ action("wait", "Wait", ParamSpec.of("ticks", "Ticks", Kind.INT, "1"));
+ action("select_slot", "Select hotbar slot", ParamSpec.of("slot", "Slot (0-8)", Kind.INT, "0"));
+ action("restore_slot", "Restore starting slot");
+ action("select_item", "Select item on hotbar",
+ ParamSpec.of("items", "Item ids", Kind.STRING_LIST, ""),
+ ParamSpec.of("tag", "Item tag", Kind.STRING, ""));
+ action("require_item", "Require item in inventory",
+ ParamSpec.of("items", "Item ids", Kind.STRING_LIST, ""),
+ ParamSpec.of("tag", "Item tag", Kind.STRING, ""));
+ action("swap_to_item", "Swap to item",
+ ParamSpec.of("items", "Item ids", Kind.STRING_LIST, ""),
+ ParamSpec.of("rankEnchant", "Rank by enchant", Kind.STRING, ""));
+ action("swap_back", "Swap back");
+ action("attack", "Attack", ParamSpec.of("swing", "Swing arm", Kind.BOOL, "false"));
+ action("use", "Use held item");
+ action("use_item", "Use specific item",
+ ParamSpec.of("item", "Item id", Kind.STRING, ""),
+ ParamSpec.choice("hotbarGate", "Only fire when", "any", List.of("any", "in_hotbar", "not_in_hotbar")),
+ ParamSpec.of("vanillaSwap", "Vanilla swap", Kind.BOOL, "false"),
+ ParamSpec.of("swapToDelay", "Swap-to ticks", Kind.INT, "2"),
+ ParamSpec.of("useDelay", "Use ticks", Kind.INT, "3"),
+ ParamSpec.of("swapBackDelay", "Swap-back ticks", Kind.INT, "2"));
+ action("shield_totem_swap", "Shield/totem offhand swap");
+ action("look", "Look toward",
+ ParamSpec.choice("direction", "Direction", "north",
+ List.of("north", "south", "east", "west", "up", "down", "left", "right")));
+ action("turn", "Turn to yaw",
+ ParamSpec.of("degrees", "Yaw degrees", Kind.FLOAT, "0"));
+ action("sprint", "Sprint",
+ ParamSpec.choice("mode", "Mode", "on", List.of("on", "off", "toggle")));
+ action("sneak", "Sneak",
+ ParamSpec.choice("mode", "Mode", "on", List.of("on", "off", "toggle")));
+ action("jump", "Jump");
+ action("double_jump", "Double jump");
+ action("move", "Move",
+ ParamSpec.choice("direction", "Direction", "forward", List.of("forward", "back", "left", "right")),
+ ParamSpec.of("blocks", "Blocks", Kind.FLOAT, "1"));
+ action("random_wait", "Random wait",
+ ParamSpec.of("min", "Min ticks", Kind.INT, "1"),
+ ParamSpec.of("max", "Max ticks", Kind.INT, "10"));
+
+ trigger("key", "Key press", Nesting.NONE,
+ List.of(ParamSpec.of("key", "Key", Kind.KEY, "-1")));
+ trigger("key_release", "Key release", Nesting.NONE,
+ List.of(ParamSpec.of("key", "Key", Kind.KEY, "-1")));
+ trigger("mouse_button", "Mouse press", Nesting.NONE,
+ List.of(ParamSpec.of("button", "Button", Kind.MOUSE, "-1")));
+ trigger("mouse_release", "Mouse release", Nesting.NONE,
+ List.of(ParamSpec.of("button", "Button", Kind.MOUSE, "-1")));
+ trigger("condition", "Player condition", Nesting.NONE,
+ List.of(ParamSpec.choice("condition", "Condition", "low_health", PlayerConditions.ids())));
+ trigger("holding", "Holding item", Nesting.NONE,
+ List.of(ParamSpec.of("items", "Item ids", Kind.STRING_LIST, "")));
+ trigger("key_combo", "Key combo", Nesting.NONE,
+ List.of(ParamSpec.of("keys", "Keys (ctrl, c)", Kind.STRING_LIST, "")));
+ trigger("slot_selected", "Hotbar slot selected", Nesting.NONE,
+ List.of(ParamSpec.of("slot", "Slot (0-8)", Kind.INT, "0")));
+ trigger("riding", "Riding a mount", Nesting.NONE, List.of());
+ trigger("held_durability", "Held durability at most", Nesting.NONE,
+ List.of(ParamSpec.of("durability", "Durability", Kind.INT, "10")));
+ trigger("all_of", "All of (AND)", Nesting.MANY, List.of());
+ trigger("any_of", "Any of (OR)", Nesting.MANY, List.of());
+ trigger("not", "Not", Nesting.ONE, List.of());
+ }
+
+ private EditorSpecs() {
+ }
+
+ private static void action(String id, String label, ParamSpec... params) {
+ ACTIONS.put(id, new TypeSpec(id, label, Nesting.NONE, List.of(params)));
+ }
+
+ private static void trigger(String id, String label, Nesting nesting, List params) {
+ TRIGGERS.put(id, new TypeSpec(id, label, nesting, params));
+ }
+
+ public static List actionTypes() {
+ return List.copyOf(ACTIONS.values());
+ }
+
+ public static List triggerTypes() {
+ return List.copyOf(TRIGGERS.values());
+ }
+
+ public static TypeSpec actionSpec(String id) {
+ return ACTIONS.get(id);
+ }
+
+ public static TypeSpec triggerSpec(String id) {
+ return TRIGGERS.get(id);
+ }
+
+ public static JsonObject defaultJson(TypeSpec spec) {
+ JsonObject json = new JsonObject();
+ json.addProperty("type", spec.id());
+ for (ParamSpec param : spec.params()) {
+ switch (param.kind()) {
+ case INT, KEY, MOUSE -> json.addProperty(param.key(), Integer.parseInt(param.def()));
+ case FLOAT -> json.addProperty(param.key(), Float.parseFloat(param.def()));
+ case BOOL -> json.addProperty(param.key(), Boolean.parseBoolean(param.def()));
+ case STRING, ENUM -> json.addProperty(param.key(), param.def());
+ case STRING_LIST -> json.add(param.key(), new JsonArray());
+ }
+ }
+ if (spec.nesting() == Nesting.MANY) {
+ json.add("children", new JsonArray());
+ }
+ return json;
+ }
+
+ public static String describeAction(JsonObject json) {
+ String type = GsonHelper.getAsString(json, "type", "");
+ return switch (type) {
+ case "wait" -> "Wait " + GsonHelper.getAsInt(json, "ticks", 1) + " ticks";
+ case "select_slot" -> "Select slot " + (GsonHelper.getAsInt(json, "slot", 0) + 1);
+ case "restore_slot" -> "Restore starting slot";
+ case "select_item" -> "Select " + itemsOrTag(json);
+ case "require_item" -> "Require " + itemsOrTag(json);
+ case "swap_to_item" -> "Swap to " + itemsOrTag(json);
+ case "swap_back" -> "Swap back";
+ case "attack" -> GsonHelper.getAsBoolean(json, "swing", false) ? "Attack (swing)" : "Attack";
+ case "use" -> "Use held item";
+ case "use_item" -> "Use " + orAny(GsonHelper.getAsString(json, "item", ""));
+ case "shield_totem_swap" -> "Shield/totem offhand swap";
+ case "look" -> "Look " + GsonHelper.getAsString(json, "direction", "north");
+ case "turn" -> "Turn to " + trimFloat(GsonHelper.getAsFloat(json, "degrees", 0.0F)) + "°";
+ case "sprint" -> "Sprint " + GsonHelper.getAsString(json, "mode", "on");
+ case "sneak" -> "Sneak " + GsonHelper.getAsString(json, "mode", "on");
+ case "jump" -> "Jump";
+ case "double_jump" -> "Double jump";
+ case "move" -> "Move " + GsonHelper.getAsString(json, "direction", "forward") + " "
+ + trimFloat(GsonHelper.getAsFloat(json, "blocks", 1.0F)) + " blocks";
+ case "random_wait" -> "Wait " + GsonHelper.getAsInt(json, "min", 1) + "-"
+ + GsonHelper.getAsInt(json, "max", 10) + " ticks";
+ default -> type;
+ };
+ }
+
+ public static String describeTrigger(JsonObject json) {
+ String type = GsonHelper.getAsString(json, "type", "");
+ return switch (type) {
+ case "key" -> "Key press: " + keyName(GsonHelper.getAsInt(json, "key", -1));
+ case "key_release" -> "Key release: " + keyName(GsonHelper.getAsInt(json, "key", -1));
+ case "mouse_button" -> "Mouse press: " + buttonName(GsonHelper.getAsInt(json, "button", -1));
+ case "mouse_release" -> "Mouse release: " + buttonName(GsonHelper.getAsInt(json, "button", -1));
+ case "condition" -> "Condition: " + GsonHelper.getAsString(json, "condition", "");
+ case "holding" -> "Holding " + orAny(joinList(json, "items"));
+ case "key_combo" -> "Combo: " + orAny(String.join(" + ", readStringList(json, "keys")));
+ case "slot_selected" -> "Slot " + (GsonHelper.getAsInt(json, "slot", 0) + 1) + " selected";
+ case "riding" -> "Riding a mount";
+ case "held_durability" -> "Held durability ≤ " + GsonHelper.getAsInt(json, "durability", 0);
+ case "all_of" -> "All of (AND)";
+ case "any_of" -> "Any of (OR)";
+ case "not" -> "Not";
+ default -> type;
+ };
+ }
+
+ public static String keyName(int key) {
+ if (key < 0) {
+ return "Unbound";
+ }
+ return InputConstants.Type.KEYSYM.getOrCreate(key).getDisplayName().getString();
+ }
+
+ public static String buttonName(int button) {
+ if (button < 0) {
+ return "Unbound";
+ }
+ return switch (button) {
+ case GLFW.GLFW_MOUSE_BUTTON_LEFT -> "Left click";
+ case GLFW.GLFW_MOUSE_BUTTON_RIGHT -> "Right click";
+ case GLFW.GLFW_MOUSE_BUTTON_MIDDLE -> "Middle click";
+ default -> "Button " + button;
+ };
+ }
+
+ private static String itemsOrTag(JsonObject json) {
+ String items = joinList(json, "items");
+ if (!items.isEmpty()) {
+ return items;
+ }
+ String tag = GsonHelper.getAsString(json, "tag", "");
+ return tag.isEmpty() ? "anything" : "#" + tag;
+ }
+
+ public static String trimFloat(float value) {
+ return value == (int) value ? String.valueOf((int) value) : String.valueOf(value);
+ }
+
+ private static String orAny(String value) {
+ return value.isEmpty() ? "anything" : value;
+ }
+
+ private static String joinList(JsonObject json, String field) {
+ StringJoiner joiner = new StringJoiner(", ");
+ for (JsonElement element : GsonHelper.getAsJsonArray(json, field, new JsonArray())) {
+ if (element.isJsonPrimitive()) {
+ joiner.add(element.getAsString());
+ }
+ }
+ return joiner.toString();
+ }
+
+ public static List readStringList(JsonObject json, String field) {
+ List values = new ArrayList<>();
+ for (JsonElement element : GsonHelper.getAsJsonArray(json, field, new JsonArray())) {
+ if (element.isJsonPrimitive()) {
+ values.add(element.getAsString());
+ }
+ }
+ return values;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java
new file mode 100644
index 0000000..945cf5b
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroEditScreen.java
@@ -0,0 +1,432 @@
+package com.kingjayan.keybinds.ui.editor;
+
+import com.google.gson.JsonObject;
+import com.kingjayan.keybinds.core.action.Action;
+import com.kingjayan.keybinds.core.action.ActionRegistry;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.macro.MacroRegistry;
+import com.kingjayan.keybinds.core.trigger.TriggerRegistry;
+import com.kingjayan.keybinds.dsl.ScriptPrinter;
+import com.kingjayan.keybinds.persist.MacroStorage;
+import com.kingjayan.keybinds.serialize.MacroCodec;
+import com.kingjayan.keybinds.ui.framework.ContextMenu;
+import com.kingjayan.keybinds.ui.framework.FrameworkScreen;
+import com.kingjayan.keybinds.ui.framework.ModalOverlay;
+import com.kingjayan.keybinds.ui.framework.ScrollPanel;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.CycleButton;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.client.gui.components.MultiLineTextWidget;
+import net.minecraft.client.gui.components.StringWidget;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+public class MacroEditScreen extends FrameworkScreen {
+
+ private static final int COLUMN_WIDTH = 340;
+ private static final int ROW_HEIGHT = 24;
+ private static final int HANDLE_WIDTH = 20;
+ private static final int UNDO_LIMIT = 50;
+
+ private static final List CLIPBOARD = new ArrayList<>();
+
+ private final MacroListScreen parent;
+ private final Macro macro;
+ private final Deque undoStack = new ArrayDeque<>();
+ private final Deque redoStack = new ArrayDeque<>();
+ private final Set selected = new LinkedHashSet<>();
+
+ private ScrollPanel actionsPanel;
+ private StringWidget triggerSummary;
+ private int dragIndex = -1;
+ private JsonObject dragSnapshot;
+
+ public MacroEditScreen(MacroListScreen parent, Macro macro) {
+ super(Component.translatable("keybinds.editor.macro_title"));
+ this.parent = parent;
+ this.macro = macro;
+ }
+
+ @Override
+ protected void init() {
+ int left = width / 2 - COLUMN_WIDTH / 2;
+ addRenderableWidget(new StringWidget(0, 8, width, 20, title, font));
+
+ EditBox nameBox = new EditBox(font, left, 24, COLUMN_WIDTH - 52, 20,
+ Component.translatable("keybinds.editor.name"));
+ nameBox.setMaxLength(64);
+ nameBox.setValue(macro.name());
+ nameBox.setResponder(value -> macro.setName(value.isBlank() ? "Unnamed" : value));
+ addRenderableWidget(nameBox);
+ addRenderableWidget(CycleButton.onOffBuilder(macro.enabled())
+ .displayOnlyValue()
+ .create(left + COLUMN_WIDTH - 44, 24, 44, 20, Component.empty(),
+ (b, value) -> macro.setEnabled(value)));
+
+ triggerSummary = addRenderableWidget(new StringWidget(left, 54, COLUMN_WIDTH - 92, 12,
+ triggerText(), font));
+ addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.edit_trigger"), b -> {
+ if (minecraft != null) {
+ minecraft.setScreenAndShow(new TriggerEditScreen(this, macro));
+ }
+ })
+ .bounds(left + COLUMN_WIDTH - 88, 50, 88, 20)
+ .build());
+
+ addRenderableWidget(new StringWidget(left, 76, COLUMN_WIDTH, 12,
+ Component.translatable("keybinds.editor.actions"), font));
+ actionsPanel = addRenderableWidget(new ScrollPanel(left, 90, COLUMN_WIDTH, height - 166));
+ fillActions();
+
+ addRenderableWidget(new StringWidget(left, height - 58, COLUMN_WIDTH, 12,
+ Component.translatable("keybinds.editor.shortcuts"), font));
+ addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.add_action"), b -> addAction())
+ .bounds(width / 2 - 160, height - 28, 104, 20)
+ .build());
+ addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.text_mode"),
+ b -> confirmTextMode())
+ .bounds(width / 2 - 52, height - 28, 104, 20)
+ .build());
+ addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose())
+ .bounds(width / 2 + 56, height - 28, 104, 20)
+ .build());
+ }
+
+ private Component triggerText() {
+ String text = macro.trigger() == null ? "No trigger"
+ : EditorSpecs.describeTrigger(TriggerRegistry.toJson(macro.trigger()));
+ return Component.literal("Trigger: " + text);
+ }
+
+ private void fillActions() {
+ selected.removeIf(index -> index >= macro.actions().size());
+ actionsPanel.clearEntries();
+ List actions = macro.actions();
+ if (actions.isEmpty()) {
+ actionsPanel.add(new StringWidget(0, 4, COLUMN_WIDTH - 8, 12,
+ Component.translatable("keybinds.editor.no_actions"), font), 0, 0);
+ return;
+ }
+ int y = 0;
+ for (int i = 0; i < actions.size(); i++) {
+ addActionRow(i, y);
+ y += ROW_HEIGHT;
+ }
+ }
+
+ private void addActionRow(int index, int y) {
+ JsonObject json = ActionRegistry.toJson(macro.actions().get(index));
+ Button handle = Button.builder(Component.literal("≡"), b -> {
+ })
+ .bounds(0, 0, HANDLE_WIDTH, 20)
+ .build();
+ handle.active = false;
+ actionsPanel.add(handle, 0, y);
+ String prefix = selected.contains(index) ? "✔ " : "";
+ actionsPanel.add(Button.builder(
+ Component.literal(prefix + (index + 1) + ". " + EditorSpecs.describeAction(json)),
+ b -> editAction(index))
+ .bounds(0, 0, COLUMN_WIDTH - HANDLE_WIDTH - 32, 20)
+ .build(), HANDLE_WIDTH + 4, y);
+ actionsPanel.add(Button.builder(Component.literal("✕"), b -> {
+ pushUndo();
+ macro.actions().remove(index);
+ fillActions();
+ })
+ .bounds(0, 0, 20, 20)
+ .build(), COLUMN_WIDTH - 28, y);
+ }
+
+ private void toggleSelected(int index) {
+ if (!selected.remove(index)) {
+ selected.add(index);
+ }
+ fillActions();
+ }
+
+ private int rowIndexAt(double mouseY) {
+ int index = ((int) mouseY - actionsPanel.getY() + (int) actionsPanel.scroll()) / ROW_HEIGHT;
+ return index >= 0 && index < macro.actions().size() ? index : -1;
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
+ if (!hasOverlay() && actionsPanel.isMouseOver(event.x(), event.y())) {
+ int index = rowIndexAt(event.y());
+ if (index >= 0 && event.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT && event.hasControlDown()) {
+ toggleSelected(index);
+ return true;
+ }
+ if (index >= 0 && event.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT
+ && event.x() < actionsPanel.getX() + HANDLE_WIDTH + 2) {
+ dragIndex = index;
+ dragSnapshot = MacroCodec.writeMacro(macro);
+ return true;
+ }
+ if (index >= 0 && event.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT) {
+ openRowMenu(index, (int) event.x(), (int) event.y());
+ return true;
+ }
+ }
+ return super.mouseClicked(event, doubleClick);
+ }
+
+ @Override
+ public boolean mouseDragged(MouseButtonEvent event, double dx, double dy) {
+ if (dragIndex >= 0) {
+ int over = rowIndexAt(event.y());
+ if (over >= 0 && over != dragIndex) {
+ if (dragSnapshot != null) {
+ recordExternalChange(dragSnapshot);
+ dragSnapshot = null;
+ }
+ Collections.swap(macro.actions(), dragIndex, over);
+ dragIndex = over;
+ fillActions();
+ }
+ return true;
+ }
+ return super.mouseDragged(event, dx, dy);
+ }
+
+ @Override
+ public boolean mouseReleased(MouseButtonEvent event) {
+ if (dragIndex >= 0) {
+ dragIndex = -1;
+ dragSnapshot = null;
+ return true;
+ }
+ return super.mouseReleased(event);
+ }
+
+ private void openRowMenu(int index, int x, int y) {
+ List entries = new ArrayList<>();
+ entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.edit"),
+ () -> editAction(index)));
+ entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.copy"),
+ () -> copyActions(index)));
+ if (!CLIPBOARD.isEmpty()) {
+ entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.paste"),
+ () -> pasteActions(index + 1)));
+ }
+ entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.duplicate"), () -> {
+ pushUndo();
+ Action copy = ActionRegistry.fromJson(ActionRegistry.toJson(macro.actions().get(index)));
+ if (copy != null) {
+ macro.actions().add(index + 1, copy);
+ }
+ fillActions();
+ }));
+ entries.add(new ContextMenu.Entry(Component.translatable("keybinds.editor.delete"), () -> {
+ pushUndo();
+ if (selected.contains(index)) {
+ deleteSelected();
+ } else {
+ macro.actions().remove(index);
+ }
+ fillActions();
+ }));
+ openContextMenu(new ContextMenu(font, x, y, entries));
+ }
+
+ private void copyActions(int fallbackIndex) {
+ CLIPBOARD.clear();
+ if (selected.isEmpty()) {
+ CLIPBOARD.add(ActionRegistry.toJson(macro.actions().get(fallbackIndex)));
+ return;
+ }
+ List ordered = new ArrayList<>(selected);
+ Collections.sort(ordered);
+ for (int index : ordered) {
+ CLIPBOARD.add(ActionRegistry.toJson(macro.actions().get(index)));
+ }
+ }
+
+ private void pasteActions(int at) {
+ if (CLIPBOARD.isEmpty()) {
+ return;
+ }
+ pushUndo();
+ int insert = Math.min(at, macro.actions().size());
+ for (JsonObject json : CLIPBOARD) {
+ Action action = ActionRegistry.fromJson(json.deepCopy());
+ if (action != null) {
+ macro.actions().add(insert++, action);
+ }
+ }
+ fillActions();
+ }
+
+ private void deleteSelected() {
+ List ordered = new ArrayList<>(selected);
+ ordered.sort(Collections.reverseOrder());
+ for (int index : ordered) {
+ macro.actions().remove(index);
+ }
+ selected.clear();
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent event) {
+ if (!hasOverlay() && event.hasControlDown() && !(getFocused() instanceof EditBox)) {
+ if (event.key() == GLFW.GLFW_KEY_Z && event.hasShiftDown()) {
+ redo();
+ return true;
+ }
+ switch (event.key()) {
+ case GLFW.GLFW_KEY_Z -> {
+ undo();
+ return true;
+ }
+ case GLFW.GLFW_KEY_Y -> {
+ redo();
+ return true;
+ }
+ case GLFW.GLFW_KEY_C -> {
+ if (!selected.isEmpty()) {
+ copyActions(-1);
+ }
+ return true;
+ }
+ case GLFW.GLFW_KEY_V -> {
+ pasteActions(macro.actions().size());
+ return true;
+ }
+ }
+ }
+ if (!hasOverlay() && event.key() == GLFW.GLFW_KEY_DELETE && !selected.isEmpty()
+ && !(getFocused() instanceof EditBox)) {
+ pushUndo();
+ deleteSelected();
+ fillActions();
+ return true;
+ }
+ return super.keyPressed(event);
+ }
+
+ void pushUndo() {
+ undoStack.push(MacroCodec.writeMacro(macro));
+ if (undoStack.size() > UNDO_LIMIT) {
+ undoStack.removeLast();
+ }
+ redoStack.clear();
+ }
+
+ void recordExternalChange(JsonObject before) {
+ undoStack.push(before);
+ if (undoStack.size() > UNDO_LIMIT) {
+ undoStack.removeLast();
+ }
+ redoStack.clear();
+ }
+
+ private void undo() {
+ if (undoStack.isEmpty()) {
+ return;
+ }
+ redoStack.push(MacroCodec.writeMacro(macro));
+ restore(undoStack.pop());
+ }
+
+ private void redo() {
+ if (redoStack.isEmpty()) {
+ return;
+ }
+ undoStack.push(MacroCodec.writeMacro(macro));
+ restore(redoStack.pop());
+ }
+
+ private void restore(JsonObject snapshot) {
+ Macro restored = MacroCodec.readMacro(snapshot);
+ macro.setName(restored.name());
+ macro.setEnabled(restored.enabled());
+ macro.setTrigger(restored.trigger());
+ macro.actions().clear();
+ macro.actions().addAll(restored.actions());
+ selected.clear();
+ rebuildWidgets();
+ }
+
+ private void addAction() {
+ pushModal(new PaletteModal(this, font, "Add action", EditorSpecs.actionTypes(), type -> {
+ Action action = ActionRegistry.fromJson(EditorSpecs.defaultJson(type));
+ if (action != null) {
+ pushUndo();
+ macro.actions().add(action);
+ fillActions();
+ if (!type.params().isEmpty()) {
+ editAction(macro.actions().size() - 1);
+ }
+ }
+ }));
+ }
+
+ private void editAction(int index) {
+ JsonObject json = ActionRegistry.toJson(macro.actions().get(index));
+ EditorSpecs.TypeSpec spec = EditorSpecs.actionSpec(json.get("type").getAsString());
+ if (spec == null || spec.params().isEmpty()) {
+ return;
+ }
+ pushModal(new ParamModal(this, font, spec.label(), json.deepCopy(), spec.params(), saved -> {
+ Action parsed = ActionRegistry.fromJson(saved);
+ if (parsed != null) {
+ pushUndo();
+ macro.actions().set(index, parsed);
+ }
+ fillActions();
+ }));
+ }
+
+ private void confirmTextMode() {
+ ModalOverlay modal = new ModalOverlay(this, null);
+ int boxWidth = 260;
+ int left = width / 2 - boxWidth / 2;
+ int top = height / 2 - 40;
+ modal.add(new MultiLineTextWidget(left, top,
+ Component.translatable("keybinds.editor.text_mode_warning"), font).setMaxWidth(boxWidth));
+ modal.add(Button.builder(Component.translatable("keybinds.editor.text_mode_confirm"), b -> {
+ modal.dismiss();
+ convertToTextMode();
+ })
+ .bounds(left, top + 44, 126, 20)
+ .build());
+ modal.add(Button.builder(Component.translatable("gui.cancel"), b -> modal.dismiss())
+ .bounds(left + boxWidth - 126, top + 44, 126, 20)
+ .build());
+ pushModal(modal);
+ }
+
+ private void convertToTextMode() {
+ macro.setScript(ScriptPrinter.print(macro));
+ macro.setTrigger(null);
+ macro.actions().clear();
+ MacroStorage.save(MacroRegistry.get().all());
+ MacroRegistry.get().touch();
+ if (minecraft != null) {
+ minecraft.setScreenAndShow(new ScriptEditScreen(parent, macro));
+ }
+ }
+
+ void refreshTrigger() {
+ triggerSummary.setMessage(triggerText());
+ }
+
+ @Override
+ public void onClose() {
+ MacroStorage.save(MacroRegistry.get().all());
+ MacroRegistry.get().touch();
+ if (minecraft != null) {
+ minecraft.setScreenAndShow(parent);
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java
new file mode 100644
index 0000000..5d10e50
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/editor/MacroListScreen.java
@@ -0,0 +1,141 @@
+package com.kingjayan.keybinds.ui.editor;
+
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.macro.MacroRegistry;
+import com.kingjayan.keybinds.persist.MacroStorage;
+import com.kingjayan.keybinds.serialize.MacroCodec;
+import com.kingjayan.keybinds.ui.framework.ContextMenu;
+import com.kingjayan.keybinds.ui.framework.FrameworkScreen;
+import com.kingjayan.keybinds.ui.framework.ScrollPanel;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.CycleButton;
+import net.minecraft.client.gui.components.StringWidget;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.List;
+import java.util.UUID;
+
+public class MacroListScreen extends FrameworkScreen {
+
+ private static final int ROW_WIDTH = 320;
+ private static final int ROW_HEIGHT = 24;
+
+ private final Screen parent;
+ private ScrollPanel panel;
+
+ public MacroListScreen(Screen parent) {
+ super(Component.translatable("keybinds.editor.title"));
+ this.parent = parent;
+ }
+
+ @Override
+ protected void init() {
+ addRenderableWidget(new StringWidget(0, 8, width, 20, title, font));
+
+ int left = width / 2 - ROW_WIDTH / 2;
+ panel = addRenderableWidget(new ScrollPanel(left, 28, ROW_WIDTH, height - 96));
+ fillPanel();
+
+ addRenderableWidget(new StringWidget(left, height - 60, ROW_WIDTH, 12,
+ Component.translatable("keybinds.editor.hint"), font));
+ addRenderableWidget(Button.builder(Component.translatable("keybinds.editor.new"), b -> {
+ Macro macro = Macro.create("New macro");
+ MacroRegistry.get().add(macro);
+ openEditor(macro);
+ })
+ .bounds(width / 2 - 154, height - 28, 150, 20)
+ .build());
+ addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose())
+ .bounds(width / 2 + 4, height - 28, 150, 20)
+ .build());
+ }
+
+ private void fillPanel() {
+ panel.clearEntries();
+ List macros = MacroRegistry.get().all();
+ if (macros.isEmpty()) {
+ panel.add(new StringWidget(0, 4, ROW_WIDTH - 8, 12,
+ Component.translatable("keybinds.editor.empty"), font), 0, 0);
+ return;
+ }
+ int y = 0;
+ for (Macro macro : macros) {
+ addRow(macro, y);
+ y += ROW_HEIGHT;
+ }
+ }
+
+ private void addRow(Macro macro, int y) {
+ panel.add(CycleButton.onOffBuilder(macro.enabled())
+ .displayOnlyValue()
+ .create(0, 0, 44, 20, Component.empty(), (b, value) -> macro.setEnabled(value)), 0, y);
+ String label = macro.script() != null ? "> " + macro.name() : macro.name();
+ panel.add(Button.builder(Component.literal(label), b -> openEditor(macro))
+ .bounds(0, 0, ROW_WIDTH - 80, 20)
+ .build(), 48, y);
+ panel.add(Button.builder(Component.literal("✕"), b -> {
+ MacroRegistry.get().remove(macro);
+ rebuildWidgets();
+ })
+ .bounds(0, 0, 20, 20)
+ .build(), ROW_WIDTH - 28, y);
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
+ if (!hasOverlay() && event.button() == GLFW.GLFW_MOUSE_BUTTON_RIGHT
+ && panel.isMouseOver(event.x(), event.y())) {
+ Macro macro = macroAt(event.y());
+ if (macro != null) {
+ openContextMenu(new ContextMenu(font, (int) event.x(), (int) event.y(), List.of(
+ new ContextMenu.Entry(Component.translatable("keybinds.editor.edit"), () -> openEditor(macro)),
+ new ContextMenu.Entry(Component.translatable("keybinds.editor.duplicate"), () -> duplicate(macro)),
+ new ContextMenu.Entry(Component.translatable("keybinds.editor.delete"), () -> {
+ MacroRegistry.get().remove(macro);
+ rebuildWidgets();
+ }))));
+ return true;
+ }
+ }
+ return super.mouseClicked(event, doubleClick);
+ }
+
+ private Macro macroAt(double mouseY) {
+ List macros = MacroRegistry.get().all();
+ int index = ((int) mouseY - panel.getY() + (int) panel.scroll()) / ROW_HEIGHT;
+ return index >= 0 && index < macros.size() ? macros.get(index) : null;
+ }
+
+ private void duplicate(Macro macro) {
+ Macro copy = MacroCodec.readMacro(MacroCodec.writeMacro(macro));
+ Macro fresh = new Macro(UUID.randomUUID(), copy.name() + " (copy)", copy.enabled(),
+ copy.trigger(), copy.actions());
+ fresh.setScript(copy.script());
+ MacroRegistry.get().add(fresh);
+ rebuildWidgets();
+ }
+
+ private void openEditor(Macro macro) {
+ save();
+ if (minecraft != null) {
+ minecraft.setScreenAndShow(macro.script() != null
+ ? new ScriptEditScreen(this, macro) : new MacroEditScreen(this, macro));
+ }
+ }
+
+ private void save() {
+ MacroStorage.save(MacroRegistry.get().all());
+ MacroRegistry.get().touch();
+ }
+
+ @Override
+ public void onClose() {
+ save();
+ if (minecraft != null) {
+ minecraft.setScreenAndShow(parent);
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/PaletteModal.java b/src/main/java/com/kingjayan/keybinds/ui/editor/PaletteModal.java
new file mode 100644
index 0000000..9f81ab5
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/editor/PaletteModal.java
@@ -0,0 +1,78 @@
+package com.kingjayan.keybinds.ui.editor;
+
+import com.kingjayan.keybinds.ui.framework.FrameworkScreen;
+import com.kingjayan.keybinds.ui.framework.ModalOverlay;
+import com.kingjayan.keybinds.ui.framework.ScrollPanel;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.client.gui.components.StringWidget;
+import net.minecraft.network.chat.Component;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Consumer;
+
+public class PaletteModal extends ModalOverlay {
+
+ private static final int PANEL_WIDTH = 240;
+ private static final int LIST_HEIGHT = 132;
+ private static final int PANEL_BACKGROUND = 0xF0101018;
+ private static final int PANEL_BORDER = 0xFF5000A0;
+
+ private final List types;
+ private final Consumer onPick;
+ private final ScrollPanel list;
+ private final int panelLeft;
+ private final int panelTop;
+ private final int panelHeight;
+
+ public PaletteModal(FrameworkScreen host, Font font, String title,
+ List types, Consumer onPick) {
+ super(host, null);
+ this.types = types;
+ this.onPick = onPick;
+ this.panelHeight = 20 + 24 + LIST_HEIGHT + 32;
+ this.panelLeft = (width - PANEL_WIDTH) / 2;
+ this.panelTop = (height - panelHeight) / 2;
+
+ add(new StringWidget(panelLeft, panelTop + 4, PANEL_WIDTH, 12, Component.literal(title), font));
+ EditBox search = new EditBox(font, panelLeft + 8, panelTop + 20, PANEL_WIDTH - 16, 20,
+ Component.literal("Search"));
+ search.setResponder(this::refill);
+ add(search);
+ list = add(new ScrollPanel(panelLeft + 8, panelTop + 48, PANEL_WIDTH - 16, LIST_HEIGHT));
+ refill("");
+ add(Button.builder(Component.translatable("gui.cancel"), b -> dismiss())
+ .bounds(panelLeft + PANEL_WIDTH / 2 - 50, panelTop + panelHeight - 26, 100, 20)
+ .build());
+ }
+
+ private void refill(String filter) {
+ String query = filter.trim().toLowerCase(Locale.ROOT);
+ list.clearEntries();
+ int y = 0;
+ for (EditorSpecs.TypeSpec type : types) {
+ if (!query.isEmpty() && !type.label().toLowerCase(Locale.ROOT).contains(query)
+ && !type.id().contains(query)) {
+ continue;
+ }
+ list.add(Button.builder(Component.literal(type.label()), b -> {
+ dismiss();
+ onPick.accept(type);
+ })
+ .bounds(0, 0, list.getWidth() - 8, 20)
+ .build(), 0, y);
+ y += 22;
+ }
+ }
+
+ @Override
+ protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
+ graphics.fill(0, 0, width, height, 0x90000000);
+ graphics.fill(panelLeft - 1, panelTop - 1, panelLeft + PANEL_WIDTH + 1, panelTop + panelHeight + 1, PANEL_BORDER);
+ graphics.fill(panelLeft, panelTop, panelLeft + PANEL_WIDTH, panelTop + panelHeight, PANEL_BACKGROUND);
+ super.extractWidgetRenderState(graphics, mouseX, mouseY, a);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java b/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java
new file mode 100644
index 0000000..3c291c7
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/editor/ParamModal.java
@@ -0,0 +1,220 @@
+package com.kingjayan.keybinds.ui.editor;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.kingjayan.keybinds.ui.framework.FrameworkScreen;
+import com.kingjayan.keybinds.ui.framework.ModalOverlay;
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.CycleButton;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.client.gui.components.StringWidget;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+import net.minecraft.util.GsonHelper;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+public class ParamModal extends ModalOverlay {
+
+ private static final int PANEL_WIDTH = 280;
+ private static final int LABEL_WIDTH = 110;
+ private static final int CONTROL_WIDTH = 150;
+ private static final int ROW_HEIGHT = 24;
+ private static final int PANEL_BACKGROUND = 0xF0101018;
+ private static final int PANEL_BORDER = 0xFF5000A0;
+
+ private final Font font;
+ private final JsonObject working;
+ private final List writers = new ArrayList<>();
+ private final int panelLeft;
+ private final int panelTop;
+ private final int panelHeight;
+
+ private Button capturingButton;
+ private EditorSpecs.ParamSpec capturingSpec;
+
+ public ParamModal(FrameworkScreen host, Font font, String title, JsonObject working,
+ List specs, Consumer onSave) {
+ super(host, null);
+ this.font = font;
+ this.working = working;
+ this.panelHeight = 20 + specs.size() * ROW_HEIGHT + 28;
+ this.panelLeft = (width - PANEL_WIDTH) / 2;
+ this.panelTop = (height - panelHeight) / 2;
+ dismissOnOutsideClick(false);
+
+ add(new StringWidget(panelLeft, panelTop + 4, PANEL_WIDTH, 12, Component.literal(title), font));
+ int y = panelTop + 20;
+ for (EditorSpecs.ParamSpec spec : specs) {
+ addRow(spec, y);
+ y += ROW_HEIGHT;
+ }
+ int buttonY = y + 4;
+ add(Button.builder(Component.translatable("gui.done"), b -> {
+ for (Runnable writer : writers) {
+ writer.run();
+ }
+ dismiss();
+ onSave.accept(working);
+ })
+ .bounds(panelLeft + PANEL_WIDTH / 2 - 102, buttonY, 100, 20)
+ .build());
+ add(Button.builder(Component.translatable("gui.cancel"), b -> dismiss())
+ .bounds(panelLeft + PANEL_WIDTH / 2 + 2, buttonY, 100, 20)
+ .build());
+ }
+
+ private void addRow(EditorSpecs.ParamSpec spec, int y) {
+ add(new StringWidget(panelLeft + 8, y + 5, LABEL_WIDTH, 12, Component.literal(spec.label()), font));
+ int x = panelLeft + PANEL_WIDTH - CONTROL_WIDTH - 8;
+ switch (spec.kind()) {
+ case INT -> {
+ EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label()));
+ box.setValue(String.valueOf(GsonHelper.getAsInt(working, spec.key(), Integer.parseInt(spec.def()))));
+ add(box);
+ writers.add(() -> working.addProperty(spec.key(),
+ parsable(box.getValue()) ? Integer.parseInt(box.getValue()) : Integer.parseInt(spec.def())));
+ }
+ case FLOAT -> {
+ EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label()));
+ box.setValue(EditorSpecs.trimFloat(
+ GsonHelper.getAsFloat(working, spec.key(), Float.parseFloat(spec.def()))));
+ add(box);
+ writers.add(() -> working.addProperty(spec.key(),
+ floatParsable(box.getValue()) ? Float.parseFloat(box.getValue()) : Float.parseFloat(spec.def())));
+ }
+ case STRING -> {
+ EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label()));
+ box.setMaxLength(256);
+ box.setValue(GsonHelper.getAsString(working, spec.key(), spec.def()));
+ add(box);
+ writers.add(() -> working.addProperty(spec.key(), box.getValue().trim()));
+ }
+ case STRING_LIST -> {
+ EditBox box = new EditBox(font, x, y, CONTROL_WIDTH, 20, Component.literal(spec.label()));
+ box.setMaxLength(512);
+ box.setValue(String.join(", ", EditorSpecs.readStringList(working, spec.key())));
+ add(box);
+ writers.add(() -> {
+ JsonArray array = new JsonArray();
+ for (String part : box.getValue().split(",")) {
+ String trimmed = part.trim();
+ if (!trimmed.isEmpty()) {
+ array.add(trimmed);
+ }
+ }
+ working.add(spec.key(), array);
+ });
+ }
+ case BOOL -> {
+ CycleButton button = CycleButton.onOffBuilder(
+ GsonHelper.getAsBoolean(working, spec.key(), Boolean.parseBoolean(spec.def())))
+ .displayOnlyValue()
+ .create(x, y, CONTROL_WIDTH, 20, Component.empty(),
+ (b, value) -> working.addProperty(spec.key(), value));
+ add(button);
+ }
+ case ENUM -> {
+ String current = GsonHelper.getAsString(working, spec.key(), spec.def());
+ if (!spec.options().contains(current)) {
+ current = spec.def();
+ }
+ CycleButton button = CycleButton.builder(Component::literal, current)
+ .withValues(spec.options())
+ .displayOnlyValue()
+ .create(x, y, CONTROL_WIDTH, 20, Component.empty(),
+ (b, value) -> working.addProperty(spec.key(), value));
+ add(button);
+ }
+ case KEY, MOUSE -> {
+ int current = GsonHelper.getAsInt(working, spec.key(), -1);
+ Button button = Button.builder(captureLabel(spec, current), b -> {
+ capturingButton = b;
+ capturingSpec = spec;
+ b.setMessage(Component.literal("> press <"));
+ })
+ .bounds(x, y, CONTROL_WIDTH, 20)
+ .build();
+ add(button);
+ }
+ }
+ }
+
+ private static Component captureLabel(EditorSpecs.ParamSpec spec, int code) {
+ return Component.literal(spec.kind() == EditorSpecs.Kind.KEY
+ ? EditorSpecs.keyName(code) : EditorSpecs.buttonName(code));
+ }
+
+ private static boolean parsable(String text) {
+ try {
+ Integer.parseInt(text);
+ return true;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+
+ private static boolean floatParsable(String text) {
+ try {
+ Float.parseFloat(text);
+ return true;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent event) {
+ if (capturingSpec != null && capturingSpec.kind() == EditorSpecs.Kind.KEY) {
+ int assigned = event.key() == GLFW.GLFW_KEY_ESCAPE ? -1 : event.key();
+ working.addProperty(capturingSpec.key(), assigned);
+ capturingButton.setMessage(captureLabel(capturingSpec, assigned));
+ endCapture();
+ return true;
+ }
+ if (capturingSpec != null) {
+ if (event.isEscape()) {
+ working.addProperty(capturingSpec.key(), -1);
+ capturingButton.setMessage(captureLabel(capturingSpec, -1));
+ }
+ endCapture();
+ return true;
+ }
+ return super.keyPressed(event);
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
+ if (capturingSpec != null) {
+ if (capturingSpec.kind() == EditorSpecs.Kind.MOUSE) {
+ working.addProperty(capturingSpec.key(), event.button());
+ capturingButton.setMessage(captureLabel(capturingSpec, event.button()));
+ } else {
+ capturingButton.setMessage(captureLabel(capturingSpec,
+ GsonHelper.getAsInt(working, capturingSpec.key(), -1)));
+ }
+ endCapture();
+ return true;
+ }
+ return super.mouseClicked(event, doubleClick);
+ }
+
+ private void endCapture() {
+ capturingButton = null;
+ capturingSpec = null;
+ }
+
+ @Override
+ protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
+ graphics.fill(0, 0, width, height, 0x90000000);
+ graphics.fill(panelLeft - 1, panelTop - 1, panelLeft + PANEL_WIDTH + 1, panelTop + panelHeight + 1, PANEL_BORDER);
+ graphics.fill(panelLeft, panelTop, panelLeft + PANEL_WIDTH, panelTop + panelHeight, PANEL_BACKGROUND);
+ super.extractWidgetRenderState(graphics, mouseX, mouseY, a);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/ScriptEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/ScriptEditScreen.java
new file mode 100644
index 0000000..6d42781
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/editor/ScriptEditScreen.java
@@ -0,0 +1,135 @@
+package com.kingjayan.keybinds.ui.editor;
+
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.macro.MacroRegistry;
+import com.kingjayan.keybinds.dsl.ScriptCompiler;
+import com.kingjayan.keybinds.dsl.ScriptDocs;
+import com.kingjayan.keybinds.dsl.ScriptException;
+import com.kingjayan.keybinds.persist.MacroStorage;
+import com.kingjayan.keybinds.ui.framework.FrameworkScreen;
+import com.kingjayan.keybinds.ui.framework.ScrollPanel;
+import net.minecraft.ChatFormatting;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.CycleButton;
+import net.minecraft.client.gui.components.EditBox;
+import net.minecraft.client.gui.components.MultiLineTextWidget;
+import net.minecraft.client.gui.components.StringWidget;
+import net.minecraft.network.chat.Component;
+
+public class ScriptEditScreen extends FrameworkScreen {
+
+ private final MacroListScreen parent;
+ private final Macro macro;
+
+ private CodeEditor editor;
+ private StringWidget status;
+
+ public ScriptEditScreen(MacroListScreen parent, Macro macro) {
+ super(Component.translatable("keybinds.editor.script_title"));
+ this.parent = parent;
+ this.macro = macro;
+ }
+
+ @Override
+ protected void init() {
+ int margin = 10;
+ int docsWidth = Math.min(200, width / 3);
+ int editorWidth = width - docsWidth - margin * 3;
+ int left = margin;
+
+ addRenderableWidget(new StringWidget(0, 8, width, 20, title, font));
+
+ EditBox nameBox = new EditBox(font, left, 24, editorWidth - 52, 20,
+ Component.translatable("keybinds.editor.name"));
+ nameBox.setMaxLength(64);
+ nameBox.setValue(macro.name());
+ nameBox.setResponder(value -> macro.setName(value.isBlank() ? "Unnamed" : value));
+ addRenderableWidget(nameBox);
+ addRenderableWidget(CycleButton.onOffBuilder(macro.enabled())
+ .displayOnlyValue()
+ .create(left + editorWidth - 44, 24, 44, 20, Component.empty(),
+ (b, value) -> macro.setEnabled(value)));
+
+ editor = addRenderableWidget(new CodeEditor(left, 50, editorWidth, height - 106, font,
+ this::compileCheck));
+ editor.setText(macro.script() == null ? "" : macro.script());
+
+ status = addRenderableWidget(new StringWidget(left, height - 50, editorWidth, 12,
+ Component.empty(), font));
+ compileCheck(editor.text());
+
+ ScrollPanel docs = addRenderableWidget(new ScrollPanel(left + editorWidth + margin, 24,
+ docsWidth, height - 60));
+ fillDocs(docs, docsWidth);
+
+ addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose())
+ .bounds(left, height - 28, 150, 20)
+ .build());
+ }
+
+ private void compileCheck(String text) {
+ if (status == null) {
+ return;
+ }
+ try {
+ ScriptCompiler.compile(text);
+ editor.setErrorLine(-1);
+ status.setMessage(Component.translatable("keybinds.editor.script_ok")
+ .withStyle(ChatFormatting.GREEN));
+ } catch (ScriptException e) {
+ editor.setErrorLine(e.line() - 1);
+ status.setMessage(Component.literal("Line " + e.line() + ": " + e.getMessage())
+ .withStyle(ChatFormatting.RED));
+ } catch (Exception e) {
+ editor.setErrorLine(-1);
+ status.setMessage(Component.literal(String.valueOf(e.getMessage()))
+ .withStyle(ChatFormatting.RED));
+ }
+ }
+
+ private void fillDocs(ScrollPanel docs, int docsWidth) {
+ int textWidth = docsWidth - 12;
+ int y = 0;
+ for (ScriptDocs.Entry entry : ScriptDocs.entries()) {
+ switch (entry.kind()) {
+ case HEADER -> {
+ y += y == 0 ? 0 : 6;
+ y = addWrapped(docs, Component.literal(entry.name())
+ .withStyle(ChatFormatting.GOLD, ChatFormatting.BOLD), textWidth, y);
+ }
+ case TEXT -> {
+ y = addWrapped(docs, Component.literal(entry.description())
+ .withStyle(ChatFormatting.GRAY), textWidth, y);
+ y += 3;
+ }
+ case TRIGGER, CONDITION, ACTION -> {
+ ChatFormatting color = entry.kind() == ScriptDocs.Kind.ACTION
+ ? ChatFormatting.GREEN : ChatFormatting.AQUA;
+ y = addWrapped(docs, Component.literal(entry.signature()).withStyle(color),
+ textWidth, y);
+ if (!entry.description().isEmpty()) {
+ y = addWrapped(docs, Component.literal(entry.description())
+ .withStyle(ChatFormatting.GRAY), textWidth, y);
+ }
+ y += 3;
+ }
+ }
+ }
+ }
+
+ private int addWrapped(ScrollPanel docs, Component text, int textWidth, int y) {
+ MultiLineTextWidget widget = new MultiLineTextWidget(0, 0, text, font).setMaxWidth(textWidth);
+ docs.add(widget, 4, y);
+ return y + widget.getHeight();
+ }
+
+ @Override
+ public void onClose() {
+ macro.setScript(editor.text());
+ MacroStorage.save(MacroRegistry.get().all());
+ MacroRegistry.get().touch();
+ if (minecraft != null) {
+ minecraft.setScreenAndShow(parent);
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java b/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java
new file mode 100644
index 0000000..8a180e6
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/editor/TriggerEditScreen.java
@@ -0,0 +1,151 @@
+package com.kingjayan.keybinds.ui.editor;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.trigger.TriggerRegistry;
+import com.kingjayan.keybinds.serialize.MacroCodec;
+import com.kingjayan.keybinds.ui.framework.FrameworkScreen;
+import com.kingjayan.keybinds.ui.framework.ScrollPanel;
+import net.minecraft.client.gui.components.Button;
+import net.minecraft.client.gui.components.StringWidget;
+import net.minecraft.network.chat.Component;
+import net.minecraft.util.GsonHelper;
+
+import java.util.Map;
+import java.util.function.Consumer;
+
+public class TriggerEditScreen extends FrameworkScreen {
+
+ private static final int COLUMN_WIDTH = 340;
+ private static final int ROW_HEIGHT = 24;
+ private static final int INDENT = 14;
+
+ private final MacroEditScreen parent;
+ private final Macro macro;
+ private final JsonObject before;
+
+ private JsonObject root;
+ private ScrollPanel panel;
+ private int rowY;
+
+ public TriggerEditScreen(MacroEditScreen parent, Macro macro) {
+ super(Component.translatable("keybinds.editor.trigger_title"));
+ this.parent = parent;
+ this.macro = macro;
+ this.root = macro.trigger() == null ? null : TriggerRegistry.toJson(macro.trigger());
+ this.before = MacroCodec.writeMacro(macro);
+ }
+
+ @Override
+ protected void init() {
+ int left = width / 2 - COLUMN_WIDTH / 2;
+ addRenderableWidget(new StringWidget(0, 8, width, 20, title, font));
+ panel = addRenderableWidget(new ScrollPanel(left, 28, COLUMN_WIDTH, height - 90));
+ rowY = 0;
+ if (root == null) {
+ panel.add(Button.builder(Component.translatable("keybinds.editor.set_trigger"),
+ b -> pickType(json -> {
+ root = json;
+ rebuildWidgets();
+ }))
+ .bounds(0, 0, 160, 20)
+ .build(), 0, 0);
+ } else {
+ addNodeRows(root, 0, () -> root = null);
+ }
+ addRenderableWidget(new StringWidget(left, height - 54, COLUMN_WIDTH, 12,
+ Component.translatable("keybinds.editor.trigger_hint"), font));
+ addRenderableWidget(Button.builder(Component.translatable("gui.done"), b -> onClose())
+ .bounds(width / 2 - 75, height - 28, 150, 20)
+ .build());
+ }
+
+ private void addNodeRows(JsonObject node, int depth, Runnable remover) {
+ String type = GsonHelper.getAsString(node, "type", "");
+ EditorSpecs.TypeSpec spec = EditorSpecs.triggerSpec(type);
+ int indent = depth * INDENT;
+ int y = rowY;
+ rowY += ROW_HEIGHT;
+
+ panel.add(new StringWidget(indent, y + 6, COLUMN_WIDTH - indent - 84, 12,
+ Component.literal(EditorSpecs.describeTrigger(node)), font), indent, y);
+
+ int x = COLUMN_WIDTH - 80;
+ if (spec != null && !spec.params().isEmpty()) {
+ panel.add(Button.builder(Component.literal("✎"), b -> editParams(node, spec))
+ .bounds(0, 0, 20, 20)
+ .build(), x, y);
+ }
+ x += 24;
+ if (spec != null && spec.nesting() == EditorSpecs.Nesting.MANY) {
+ JsonArray children = GsonHelper.getAsJsonArray(node, "children", null);
+ if (children == null) {
+ JsonArray created = new JsonArray();
+ node.add("children", created);
+ children = created;
+ }
+ JsonArray target = children;
+ panel.add(Button.builder(Component.literal("+"), b -> pickType(json -> {
+ target.add(json);
+ rebuildWidgets();
+ }))
+ .bounds(0, 0, 20, 20)
+ .build(), x, y);
+ } else if (spec != null && spec.nesting() == EditorSpecs.Nesting.ONE
+ && !(node.get("child") instanceof JsonObject)) {
+ panel.add(Button.builder(Component.literal("+"), b -> pickType(json -> {
+ node.add("child", json);
+ rebuildWidgets();
+ }))
+ .bounds(0, 0, 20, 20)
+ .build(), x, y);
+ }
+ x += 24;
+ panel.add(Button.builder(Component.literal("✕"), b -> {
+ remover.run();
+ rebuildWidgets();
+ })
+ .bounds(0, 0, 20, 20)
+ .build(), x, y);
+
+ if (spec != null && spec.nesting() == EditorSpecs.Nesting.MANY) {
+ JsonArray children = GsonHelper.getAsJsonArray(node, "children", new JsonArray());
+ for (int i = 0; i < children.size(); i++) {
+ if (children.get(i) instanceof JsonObject child) {
+ int index = i;
+ addNodeRows(child, depth + 1, () -> children.remove(index));
+ }
+ }
+ } else if (spec != null && spec.nesting() == EditorSpecs.Nesting.ONE
+ && node.get("child") instanceof JsonObject child) {
+ addNodeRows(child, depth + 1, remover);
+ }
+ }
+
+ private void pickType(Consumer onCreate) {
+ pushModal(new PaletteModal(this, font, "Add trigger", EditorSpecs.triggerTypes(),
+ type -> onCreate.accept(EditorSpecs.defaultJson(type))));
+ }
+
+ private void editParams(JsonObject node, EditorSpecs.TypeSpec spec) {
+ pushModal(new ParamModal(this, font, spec.label(), node.deepCopy(), spec.params(), saved -> {
+ for (Map.Entry entry : saved.entrySet()) {
+ node.add(entry.getKey(), entry.getValue());
+ }
+ rebuildWidgets();
+ }));
+ }
+
+ @Override
+ public void onClose() {
+ macro.setTrigger(root == null ? null : TriggerRegistry.fromJson(root));
+ if (!before.equals(MacroCodec.writeMacro(macro))) {
+ parent.recordExternalChange(before);
+ }
+ parent.refreshTrigger();
+ if (minecraft != null) {
+ minecraft.setScreenAndShow(parent);
+ }
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/Animator.java b/src/main/java/com/kingjayan/keybinds/ui/framework/Animator.java
new file mode 100644
index 0000000..6de3947
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/framework/Animator.java
@@ -0,0 +1,42 @@
+package com.kingjayan.keybinds.ui.framework;
+
+import net.minecraft.util.Mth;
+
+public final class Animator {
+
+ private final Easing easing;
+ private float from;
+ private float to;
+ private int durationTicks;
+ private int elapsed;
+
+ public Animator(Easing easing) {
+ this.easing = easing;
+ }
+
+ public void start(float from, float to, int durationTicks) {
+ this.from = from;
+ this.to = to;
+ this.durationTicks = Math.max(1, durationTicks);
+ this.elapsed = 0;
+ }
+
+ public void retarget(float to, int durationTicks) {
+ start(value(0.0F), to, durationTicks);
+ }
+
+ public void tick() {
+ if (elapsed < durationTicks) {
+ elapsed++;
+ }
+ }
+
+ public float value(float partialTick) {
+ float progress = Mth.clamp((elapsed + partialTick) / durationTicks, 0.0F, 1.0F);
+ return Mth.lerp(easing.apply(progress), from, to);
+ }
+
+ public boolean finished() {
+ return elapsed >= durationTicks;
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/ContextMenu.java b/src/main/java/com/kingjayan/keybinds/ui/framework/ContextMenu.java
new file mode 100644
index 0000000..d189d7d
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/framework/ContextMenu.java
@@ -0,0 +1,74 @@
+package com.kingjayan.keybinds.ui.framework;
+
+import net.minecraft.client.gui.Font;
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+import java.util.List;
+
+public class ContextMenu extends AbstractWidget {
+
+ private static final int PADDING = 3;
+ private static final int ROW_HEIGHT = 12;
+ private static final int BACKGROUND = 0xF0100010;
+ private static final int BORDER = 0xFF5000A0;
+ private static final int HOVER = 0x40FFFFFF;
+
+ private final Font font;
+ private final List entries;
+
+ public ContextMenu(Font font, int x, int y, List entries) {
+ super(x, y, menuWidth(font, entries), entries.size() * ROW_HEIGHT + PADDING * 2, Component.empty());
+ this.font = font;
+ this.entries = entries;
+ }
+
+ private static int menuWidth(Font font, List entries) {
+ int widest = 0;
+ for (Entry entry : entries) {
+ widest = Math.max(widest, font.width(entry.label));
+ }
+ return widest + PADDING * 2 + 8;
+ }
+
+ @Override
+ public void onClick(MouseButtonEvent event, boolean doubleClick) {
+ int index = hoveredIndex(event.y());
+ if (index >= 0) {
+ entries.get(index).action.run();
+ }
+ }
+
+ private int hoveredIndex(double mouseY) {
+ int index = ((int) mouseY - getY() - PADDING) / ROW_HEIGHT;
+ return index >= 0 && index < entries.size() ? index : -1;
+ }
+
+ @Override
+ protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
+ int left = getX();
+ int top = getY();
+ int right = left + width;
+ int bottom = top + height;
+ graphics.fill(left - 1, top - 1, right + 1, bottom + 1, BORDER);
+ graphics.fill(left, top, right, bottom, BACKGROUND);
+ int hovered = isMouseOver(mouseX, mouseY) ? hoveredIndex(mouseY) : -1;
+ for (int i = 0; i < entries.size(); i++) {
+ int rowTop = top + PADDING + i * ROW_HEIGHT;
+ if (i == hovered) {
+ graphics.fill(left + 1, rowTop - 1, right - 1, rowTop + ROW_HEIGHT - 1, HOVER);
+ }
+ graphics.text(font, entries.get(i).label, left + PADDING + 2, rowTop + 1, 0xFFFFFFFF);
+ }
+ }
+
+ @Override
+ protected void updateWidgetNarration(NarrationElementOutput output) {
+ }
+
+ public record Entry(Component label, Runnable action) {
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/Easing.java b/src/main/java/com/kingjayan/keybinds/ui/framework/Easing.java
new file mode 100644
index 0000000..7a1993d
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/framework/Easing.java
@@ -0,0 +1,30 @@
+package com.kingjayan.keybinds.ui.framework;
+
+public enum Easing {
+ LINEAR {
+ @Override
+ public float apply(float t) {
+ return t;
+ }
+ },
+ EASE_IN {
+ @Override
+ public float apply(float t) {
+ return t * t;
+ }
+ },
+ EASE_OUT {
+ @Override
+ public float apply(float t) {
+ return 1.0F - (1.0F - t) * (1.0F - t);
+ }
+ },
+ EASE_IN_OUT {
+ @Override
+ public float apply(float t) {
+ return t < 0.5F ? 2.0F * t * t : 1.0F - 2.0F * (1.0F - t) * (1.0F - t);
+ }
+ };
+
+ public abstract float apply(float t);
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/FrameworkScreen.java b/src/main/java/com/kingjayan/keybinds/ui/framework/FrameworkScreen.java
new file mode 100644
index 0000000..dc1eae6
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/framework/FrameworkScreen.java
@@ -0,0 +1,118 @@
+package com.kingjayan.keybinds.ui.framework;
+
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.screens.Screen;
+import net.minecraft.client.input.CharacterEvent;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+public abstract class FrameworkScreen extends Screen {
+
+ private AbstractWidget overlay;
+
+ protected FrameworkScreen(Component title) {
+ super(title);
+ }
+
+ public void pushModal(ModalOverlay modal) {
+ overlay = modal;
+ }
+
+ public void openContextMenu(ContextMenu menu) {
+ menu.setX(Math.max(0, Math.min(menu.getX(), width - menu.getWidth())));
+ menu.setY(Math.max(0, Math.min(menu.getY(), height - menu.getHeight())));
+ overlay = menu;
+ }
+
+ public void closeOverlay() {
+ overlay = null;
+ }
+
+ public boolean hasOverlay() {
+ return overlay != null;
+ }
+
+ @Override
+ public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
+ super.extractRenderState(graphics, mouseX, mouseY, a);
+ if (overlay != null) {
+ graphics.nextStratum();
+ overlay.extractRenderState(graphics, mouseX, mouseY, a);
+ }
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
+ if (overlay == null) {
+ return super.mouseClicked(event, doubleClick);
+ }
+ if (overlay instanceof ContextMenu menu) {
+ boolean over = menu.isMouseOver(event.x(), event.y());
+ closeOverlay();
+ if (over) {
+ menu.mouseClicked(event, doubleClick);
+ }
+ return true;
+ }
+ overlay.mouseClicked(event, doubleClick);
+ return true;
+ }
+
+ @Override
+ public boolean mouseReleased(MouseButtonEvent event) {
+ if (overlay == null) {
+ return super.mouseReleased(event);
+ }
+ overlay.mouseReleased(event);
+ return true;
+ }
+
+ @Override
+ public boolean mouseDragged(MouseButtonEvent event, double dx, double dy) {
+ if (overlay == null) {
+ return super.mouseDragged(event, dx, dy);
+ }
+ overlay.mouseDragged(event, dx, dy);
+ return true;
+ }
+
+ @Override
+ public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) {
+ if (overlay == null) {
+ return super.mouseScrolled(x, y, scrollX, scrollY);
+ }
+ overlay.mouseScrolled(x, y, scrollX, scrollY);
+ return true;
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent event) {
+ if (overlay == null) {
+ return super.keyPressed(event);
+ }
+ if (overlay.keyPressed(event)) {
+ return true;
+ }
+ if (event.isEscape()) {
+ closeOverlay();
+ }
+ return true;
+ }
+
+ @Override
+ public boolean charTyped(CharacterEvent event) {
+ if (overlay == null) {
+ return super.charTyped(event);
+ }
+ overlay.charTyped(event);
+ return true;
+ }
+
+ @Override
+ protected void rebuildWidgets() {
+ overlay = null;
+ super.rebuildWidgets();
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/Layouts.java b/src/main/java/com/kingjayan/keybinds/ui/framework/Layouts.java
new file mode 100644
index 0000000..17a1525
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/framework/Layouts.java
@@ -0,0 +1,37 @@
+package com.kingjayan.keybinds.ui.framework;
+
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.layouts.FrameLayout;
+import net.minecraft.client.gui.layouts.Layout;
+import net.minecraft.client.gui.layouts.LinearLayout;
+
+import java.util.function.Consumer;
+
+public final class Layouts {
+
+ private Layouts() {
+ }
+
+ public static LinearLayout vertical(int spacing) {
+ return LinearLayout.vertical().spacing(spacing);
+ }
+
+ public static LinearLayout horizontal(int spacing) {
+ return LinearLayout.horizontal().spacing(spacing);
+ }
+
+ public static void arrange(Layout layout, int x, int y) {
+ layout.arrangeElements();
+ layout.setX(x);
+ layout.setY(y);
+ }
+
+ public static void center(Layout layout, int areaWidth, int areaHeight) {
+ layout.arrangeElements();
+ FrameLayout.centerInRectangle(layout, 0, 0, areaWidth, areaHeight);
+ }
+
+ public static void collect(Layout layout, Consumer sink) {
+ layout.visitWidgets(sink);
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java b/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java
new file mode 100644
index 0000000..2ca4742
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/framework/ModalOverlay.java
@@ -0,0 +1,100 @@
+package com.kingjayan.keybinds.ui.framework;
+
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractContainerWidget;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.input.KeyEvent;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ModalOverlay extends AbstractContainerWidget {
+
+ private static final int DIM = 0x90000000;
+
+ protected final List content = new ArrayList<>();
+ private final FrameworkScreen host;
+ private final Runnable onDismiss;
+ private boolean dismissOnOutsideClick = true;
+
+ public ModalOverlay(FrameworkScreen host, Runnable onDismiss) {
+ super(0, 0, host.width, host.height, Component.empty());
+ this.host = host;
+ this.onDismiss = onDismiss;
+ }
+
+ public ModalOverlay dismissOnOutsideClick(boolean value) {
+ this.dismissOnOutsideClick = value;
+ return this;
+ }
+
+ public T add(T widget) {
+ content.add(widget);
+ return widget;
+ }
+
+ public void dismiss() {
+ host.closeOverlay();
+ if (onDismiss != null) {
+ onDismiss.run();
+ }
+ }
+
+ @Override
+ public List extends GuiEventListener> children() {
+ return content;
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
+ for (AbstractWidget widget : content) {
+ if (widget.isMouseOver(event.x(), event.y())) {
+ return super.mouseClicked(event, doubleClick) || true;
+ }
+ }
+ if (dismissOnOutsideClick) {
+ dismiss();
+ }
+ return true;
+ }
+
+ @Override
+ public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) {
+ for (AbstractWidget widget : content) {
+ if (widget.isMouseOver(x, y) && widget.mouseScrolled(x, y, scrollX, scrollY)) {
+ return true;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public boolean keyPressed(KeyEvent event) {
+ if (event.isEscape()) {
+ dismiss();
+ return true;
+ }
+ return super.keyPressed(event);
+ }
+
+ @Override
+ protected int contentHeight() {
+ return height;
+ }
+
+ @Override
+ protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
+ graphics.fill(0, 0, width, height, DIM);
+ for (AbstractWidget widget : content) {
+ widget.extractRenderState(graphics, mouseX, mouseY, a);
+ }
+ }
+
+ @Override
+ protected void updateWidgetNarration(NarrationElementOutput output) {
+ }
+}
diff --git a/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java b/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java
new file mode 100644
index 0000000..a714fb7
--- /dev/null
+++ b/src/main/java/com/kingjayan/keybinds/ui/framework/ScrollPanel.java
@@ -0,0 +1,119 @@
+package com.kingjayan.keybinds.ui.framework;
+
+import net.minecraft.client.gui.GuiGraphicsExtractor;
+import net.minecraft.client.gui.components.AbstractContainerWidget;
+import net.minecraft.client.gui.components.AbstractWidget;
+import net.minecraft.client.gui.components.events.GuiEventListener;
+import net.minecraft.client.gui.narration.NarrationElementOutput;
+import net.minecraft.client.input.MouseButtonEvent;
+import net.minecraft.network.chat.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ScrollPanel extends AbstractContainerWidget {
+
+ private final List entries = new ArrayList<>();
+ private final List widgets = new ArrayList<>();
+ private int contentHeight;
+
+ public ScrollPanel(int x, int y, int width, int height) {
+ super(x, y, width, height, Component.empty());
+ }
+
+ public T add(T widget, int relX, int relY) {
+ entries.add(new Entry(widget, relX, relY));
+ widgets.add(widget);
+ contentHeight = Math.max(contentHeight, relY + widget.getHeight());
+ applyPositions();
+ return widget;
+ }
+
+ public double scroll() {
+ return scrollAmount();
+ }
+
+ public void clearEntries() {
+ entries.clear();
+ widgets.clear();
+ contentHeight = 0;
+ setScrollAmount(0.0);
+ }
+
+ @Override
+ public List extends GuiEventListener> children() {
+ return widgets;
+ }
+
+ @Override
+ public void setScrollAmount(double amount) {
+ super.setScrollAmount(amount);
+ applyPositions();
+ }
+
+ @Override
+ public void setX(int x) {
+ super.setX(x);
+ applyPositions();
+ }
+
+ @Override
+ public void setY(int y) {
+ super.setY(y);
+ applyPositions();
+ }
+
+ private void applyPositions() {
+ int offset = (int) scrollAmount();
+ for (Entry entry : entries) {
+ entry.widget.setX(getX() + entry.relX);
+ entry.widget.setY(getY() + entry.relY - offset);
+ }
+ }
+
+ @Override
+ public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) {
+ if (!visible || !isMouseOver(event.x(), event.y())) {
+ return false;
+ }
+ return super.mouseClicked(event, doubleClick);
+ }
+
+ @Override
+ public boolean mouseScrolled(double x, double y, double scrollX, double scrollY) {
+ if (!visible || !isMouseOver(x, y)) {
+ return false;
+ }
+ return super.mouseScrolled(x, y, scrollX, scrollY);
+ }
+
+ @Override
+ protected int contentHeight() {
+ return contentHeight;
+ }
+
+ @Override
+ protected double scrollRate() {
+ return 12.0;
+ }
+
+ @Override
+ protected void extractWidgetRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float a) {
+ graphics.enableScissor(getX(), getY(), getX() + width, getY() + height);
+ for (Entry entry : entries) {
+ AbstractWidget widget = entry.widget;
+ if (widget.getY() + widget.getHeight() >= getY() && widget.getY() <= getY() + height) {
+ widget.extractRenderState(graphics, mouseX, mouseY, a);
+ }
+ }
+ graphics.disableScissor();
+ extractScrollbar(graphics, mouseX, mouseY);
+ }
+
+ @Override
+ protected void updateWidgetNarration(NarrationElementOutput output) {
+ }
+
+ private record Entry(AbstractWidget widget, int relX, int relY) {
+ }
+}
diff --git a/src/main/resources/assets/keybinds/lang/en_us.json b/src/main/resources/assets/keybinds/lang/en_us.json
index 041e2fc..06ee48a 100644
--- a/src/main/resources/assets/keybinds/lang/en_us.json
+++ b/src/main/resources/assets/keybinds/lang/en_us.json
@@ -1,71 +1,29 @@
{
- "keybinds.config.title": "Keybinds Settings",
- "keybinds.config.uses_header": "Use Item binds — each right-clicks an item on its own trigger",
- "keybinds.config.uses_note": "Type an item (Tab to autocomplete), set a key, or open ⚙ for click/condition triggers & more.",
- "keybinds.config.item_id": "Item id",
- "keybinds.config.hold": "Hold to use",
- "keybinds.config.add_slot": "+ Add slot",
- "keybinds.config.unbound": "Unbound",
- "keybinds.config.edit_tooltip": "Advanced: extra triggers, hotbar filter, vanilla swap",
- "keybinds.config.helpers_header": "Combat helpers",
- "keybinds.config.shield_totem": "Swap Shield / Totem",
- "keybinds.config.mace_slam": "Mace Stun Slam",
- "keybinds.config.lunge_swap": "Lunge Swap",
- "keybinds.config.return_slot": "Return to start slot after slam",
- "keybinds.config.vanilla": "Vanilla",
- "keybinds.config.vanilla_help": "Vanilla mode: do the swap the slower, more human-looking way — spread over a few ticks — instead of an instant same-tick action.",
- "keybinds.config.lunge_edit_tooltip": "Lunge Swap options: click triggers, held-item filter, spears, enchantment",
+ "keybinds.editor.title": "Macros",
+ "keybinds.editor.empty": "No macros yet — click New Macro to create one.",
+ "keybinds.editor.hint": "Click a macro to edit it. Right-click a row for more options.",
+ "keybinds.editor.new": "+ New Macro",
+ "keybinds.editor.edit": "Edit",
+ "keybinds.editor.duplicate": "Duplicate",
+ "keybinds.editor.delete": "Delete",
- "keybinds.lunge.title": "Lunge Swap",
- "keybinds.lunge.left_click": "Also trigger on left-click",
- "keybinds.lunge.left_click_help": "Also lunge when you left-click (on top of your normal attack), while the held-item filter passes.",
- "keybinds.lunge.right_click": "Also trigger on right-click",
- "keybinds.lunge.right_click_help": "Also lunge when you right-click, while the held-item filter passes.",
- "keybinds.lunge.held": "Held-item filter",
- "keybinds.lunge.held_hint": "held items that allow a trigger — blank = any",
- "keybinds.lunge.held_help": "Comma-separated item ids you must hold for a trigger to fire. Blank = any item. Use minecraft:air to require an empty hand.",
- "keybinds.lunge.spears": "Spear items",
- "keybinds.lunge.spears_hint": "spear item ids, comma-separated",
- "keybinds.lunge.spears_help": "Comma-separated spear item ids to swap to. Among those you carry, the one with the highest lunge level is chosen. Blank = any item that has the lunge enchantment.",
- "keybinds.lunge.enchant": "Lunge enchantment id",
- "keybinds.lunge.enchant_help": "Enchantment id used to rank spears by level. Default minecraft:lunge.",
+ "keybinds.editor.macro_title": "Edit Macro",
+ "keybinds.editor.name": "Macro name",
+ "keybinds.editor.actions": "Actions — run in order when the trigger fires",
+ "keybinds.editor.no_actions": "No actions yet — click Add Action.",
+ "keybinds.editor.add_action": "+ Add Action",
+ "keybinds.editor.edit_trigger": "Edit Trigger…",
+ "keybinds.editor.copy": "Copy",
+ "keybinds.editor.paste": "Paste below",
+ "keybinds.editor.shortcuts": "Drag ≡ to reorder · Ctrl+click selects · Ctrl+C/V, Ctrl+Z/Y, Del",
- "keybinds.edit.title": "Bind #%s",
- "keybinds.edit.triggers": "Trigger — a key OR a click",
- "keybinds.edit.hold_help": "Off: one right-click. On: hold the key so multi-tick uses finish (drawing a bow, eating). Hold applies to the key trigger only.",
- "keybinds.edit.trigger_mode": "Trigger",
- "keybinds.edit.trigger_mode_help": "How this bind fires: a key, or a left/right-click while holding the items below. Pick one — a key and a click are mutually exclusive.",
- "keybinds.edit.key": "Key",
- "keybinds.edit.key_help": "Click, then press a key to bind it. Esc clears (unbinds).",
- "keybinds.edit.click_items": "Click items",
- "keybinds.edit.click_items_hint": "held items for the click trigger — blank = any",
- "keybinds.edit.click_items_help": "Comma-separated item ids you must be holding for the click to fire. Leave blank to fire while holding anything. Your normal click still happens.",
- "keybinds.edit.conditions": "Conditions (also fire it)",
- "keybinds.edit.conditions_help": "On top of the trigger above, also fire the moment any ticked condition becomes true (once per onset, not every tick).",
+ "keybinds.editor.text_mode": "Text Mode…",
+ "keybinds.editor.text_mode_warning": "Convert this macro to text editing mode? The current trigger and actions become an editable script. This cannot be undone — the macro can never go back to the visual editor.",
+ "keybinds.editor.text_mode_confirm": "Convert",
+ "keybinds.editor.script_title": "Edit Script",
+ "keybinds.editor.script_ok": "Script OK",
- "keybinds.mode.key": "Key",
- "keybinds.mode.left_click": "Left-click",
- "keybinds.mode.right_click": "Right-click",
- "keybinds.trigger.left_click": "L-Click",
- "keybinds.trigger.right_click": "R-Click",
- "keybinds.edit.gate": "Only fire when",
- "keybinds.edit.gate_help": "Restrict firing by where the item is: always, only if it is on your hotbar, or only if it is buried in your inventory.",
- "keybinds.edit.vanilla_swap": "Vanilla swap",
- "keybinds.edit.vanilla_swap_help": "For an item buried in your inventory: visibly bring it to hand, right-click, and swap it back — instead of an instant silent swap.",
- "keybinds.edit.delays": "Swap ticks",
- "keybinds.edit.delays_help": "Ticks between the vanilla-swap steps: bring-to-hand, right-click, swap-back. 20 ticks = 1 second. Higher looks more human.",
-
- "keybinds.gate.any": "Always",
- "keybinds.gate.in_hotbar": "Item on hotbar",
- "keybinds.gate.not_in_hotbar": "Item not on hotbar",
-
- "keybinds.cond.low_health": "Low health",
- "keybinds.cond.on_fire": "On fire",
- "keybinds.cond.in_lava": "In lava",
- "keybinds.cond.falling": "Falling",
- "keybinds.cond.in_water": "In water",
- "keybinds.cond.submerged": "Submerged",
- "keybinds.cond.hurt": "Hurt",
- "keybinds.cond.sneaking": "Sneaking",
- "keybinds.cond.sprinting": "Sprinting"
+ "keybinds.editor.trigger_title": "Edit Trigger",
+ "keybinds.editor.set_trigger": "+ Set trigger",
+ "keybinds.editor.trigger_hint": "AND/OR groups nest triggers. ✎ edits, + adds a child, ✕ removes."
}
diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json
index 1c95fc8..f579ac1 100644
--- a/src/main/resources/fabric.mod.json
+++ b/src/main/resources/fabric.mod.json
@@ -2,27 +2,28 @@
"schemaVersion": 1,
"id": "keybinds",
"version": "${version}",
- "name": "keybinds",
- "description": "Client-side convenience keybinds: a user-defined list of numbered 'Use Item' keys that right-click (tap or hold) any item you pick — throwables, blocks, bows/crossbows, food — plus a shield/totem offhand swap and a two-tick axe+mace stun-slam. Configurable via Mod Menu.",
+ "name": "Keybinds",
+ "description": "Client-side macro framework: configurable Use Item binds (tap or hold), shield/totem offhand swap, mace stun-slam, lunge swap, and a tick-based macro engine with composable triggers. Manage macros in-game with Right Shift or via Mod Menu.",
"authors": [
- "Me!"
+ "KingJayan"
],
"contact": {
- "homepage": "https://fabricmc.net/",
- "sources": "https://github.com/FabricMC/fabric-example-mod"
+ "homepage": "https://github.com/KingJayan/keybinds",
+ "sources": "https://github.com/KingJayan/keybinds",
+ "issues": "https://github.com/KingJayan/keybinds/issues"
},
"license": "CC0-1.0",
"icon": "assets/keybinds/icon.png",
"environment": "client",
"entrypoints": {
"main": [
- "com.example.Keybinds"
+ "com.kingjayan.keybinds.Keybinds"
],
"client": [
- "com.example.KeybindsClient"
+ "com.kingjayan.keybinds.KeybindsClient"
],
"modmenu": [
- "com.example.ModMenuIntegration"
+ "com.kingjayan.keybinds.ModMenuIntegration"
]
},
"depends": {
diff --git a/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java b/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java
new file mode 100644
index 0000000..78117f8
--- /dev/null
+++ b/src/test/java/com/kingjayan/keybinds/ScriptDslTest.java
@@ -0,0 +1,208 @@
+package com.kingjayan.keybinds;
+
+import com.google.gson.JsonObject;
+import com.kingjayan.keybinds.core.action.ActionContext;
+import com.kingjayan.keybinds.core.action.ActionRegistry;
+import com.kingjayan.keybinds.core.action.ActionTypes;
+import com.kingjayan.keybinds.core.action.RandomWaitAction;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.schedule.TickScheduler;
+import com.kingjayan.keybinds.core.trigger.KeyComboTrigger;
+import com.kingjayan.keybinds.core.trigger.TriggerRegistry;
+import com.kingjayan.keybinds.core.trigger.TriggerTypes;
+import com.kingjayan.keybinds.dsl.ScriptCompiler;
+import com.kingjayan.keybinds.dsl.ScriptException;
+import com.kingjayan.keybinds.dsl.ScriptExecution;
+import com.kingjayan.keybinds.dsl.ScriptPrinter;
+import com.kingjayan.keybinds.dsl.ScriptProgram;
+import com.kingjayan.keybinds.serialize.MacroCodec;
+import com.kingjayan.keybinds.ui.editor.EditorSpecs;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.lwjgl.glfw.GLFW;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ScriptDslTest {
+
+ @BeforeAll
+ static void bootstrap() {
+ ActionTypes.bootstrap();
+ TriggerTypes.bootstrap();
+ }
+
+ @Test
+ void compilesTriggersAndActions() {
+ ScriptProgram program = ScriptCompiler.compile("""
+ when key(r) and (sneaking or holding("minecraft:mace")) and not riding
+ select_slot(3)
+ wait(2)
+ attack(swing=true)
+ use_item(item="minecraft:ender_pearl", useDelay=5)
+ """);
+ assertNotNull(program.trigger());
+ assertEquals(4, program.instructions().size());
+ JsonObject json = TriggerRegistry.toJson(program.trigger());
+ assertEquals("all_of", json.get("type").getAsString());
+ assertEquals(3, json.getAsJsonArray("children").size());
+ assertEquals(GLFW.GLFW_KEY_R,
+ json.getAsJsonArray("children").get(0).getAsJsonObject().get("key").getAsInt());
+ }
+
+ @Test
+ void ifElseCompilesToJumps() {
+ ScriptProgram program = ScriptCompiler.compile("""
+ when key(g)
+ if riding {
+ attack
+ } else {
+ use
+ use
+ }
+ wait(1)
+ """);
+ List instructions = program.instructions();
+ assertEquals(6, instructions.size());
+ assertEquals(ScriptProgram.Instruction.JUMP_IF_FALSE, instructions.get(0).op);
+ assertEquals(3, instructions.get(0).target);
+ assertEquals(ScriptProgram.Instruction.JUMP, instructions.get(2).op);
+ assertEquals(5, instructions.get(2).target);
+ }
+
+ @Test
+ void runtimeBranchesAndWaits() {
+ ScriptProgram program = ScriptCompiler.compile("""
+ when key(g)
+ if riding {
+ wait(50)
+ } else {
+ wait(2)
+ }
+ wait(3)
+ """);
+ Macro macro = new Macro(UUID.randomUUID(), "test", true, null, List.of());
+ ScriptExecution execution = new ScriptExecution(macro, program,
+ new ActionContext(null, new TickScheduler(), -1));
+ execution.run();
+ assertFalse(execution.finished());
+ execution.tick();
+ assertFalse(execution.finished());
+ execution.tick();
+ assertTrue(execution.finished());
+ }
+
+ @Test
+ void visualMacroConvertsAndRecompiles() {
+ JsonObject json = new JsonObject();
+ json.addProperty("id", UUID.randomUUID().toString());
+ json.addProperty("name", "converted");
+ JsonObject trigger = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("all_of"));
+ JsonObject key = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("key"));
+ key.addProperty("key", GLFW.GLFW_KEY_F);
+ JsonObject not = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("not"));
+ not.add("child", EditorSpecs.defaultJson(EditorSpecs.triggerSpec("riding")));
+ trigger.getAsJsonArray("children").add(key);
+ trigger.getAsJsonArray("children").add(not);
+ json.add("trigger", trigger);
+ com.google.gson.JsonArray actions = new com.google.gson.JsonArray();
+ for (EditorSpecs.TypeSpec spec : EditorSpecs.actionTypes()) {
+ actions.add(EditorSpecs.defaultJson(spec));
+ }
+ json.add("actions", actions);
+ Macro macro = MacroCodec.readMacro(json);
+
+ String script = ScriptPrinter.print(macro);
+ ScriptProgram program = ScriptCompiler.compile(script);
+ assertEquals(TriggerRegistry.toJson(macro.trigger()), TriggerRegistry.toJson(program.trigger()));
+ assertEquals(macro.actions().size(), program.instructions().size());
+ for (int i = 0; i < macro.actions().size(); i++) {
+ assertEquals(ActionRegistry.toJson(macro.actions().get(i)),
+ ActionRegistry.toJson(program.instructions().get(i).action));
+ }
+ }
+
+ @Test
+ void scriptFieldRoundTripsThroughCodec() {
+ Macro macro = new Macro(UUID.randomUUID(), "scripted", true, null, List.of());
+ macro.setScript("when key(r)\nattack\n");
+ JsonObject written = MacroCodec.writeMacro(macro);
+ assertFalse(written.has("trigger"));
+ assertFalse(written.has("actions"));
+ Macro reloaded = MacroCodec.readMacro(written);
+ assertEquals(macro.script(), reloaded.script());
+ assertEquals(written, MacroCodec.writeMacro(reloaded));
+ }
+
+ @Test
+ void comboTriggerCompilesAndMatches() {
+ ScriptProgram program = ScriptCompiler.compile("""
+ when combo(ctrl, c)
+ attack
+ """);
+ KeyComboTrigger combo = (KeyComboTrigger) program.trigger();
+ assertEquals(List.of("ctrl", "c"), combo.keys());
+ Set pressed = new HashSet<>(Set.of(GLFW.GLFW_KEY_RIGHT_CONTROL, GLFW.GLFW_KEY_C));
+ assertTrue(KeyComboTrigger.allDown(combo.codes(), pressed::contains));
+ pressed.remove(GLFW.GLFW_KEY_C);
+ assertFalse(KeyComboTrigger.allDown(combo.codes(), pressed::contains));
+ assertFalse(KeyComboTrigger.allDown(KeyComboTrigger.resolveAll(List.of()), key -> true));
+ JsonObject json = TriggerRegistry.toJson(program.trigger());
+ assertEquals(json, TriggerRegistry.toJson(TriggerRegistry.fromJson(json)));
+ }
+
+ @Test
+ void newActionsAndFloatsCompile() {
+ ScriptProgram program = ScriptCompiler.compile("""
+ when combo(d, r)
+ sprint(on)
+ wait(2)
+ move(forward, 1.2)
+ turn(90)
+ look(north)
+ double_jump
+ sneak(toggle)
+ random_wait(2, 5)
+ """);
+ assertEquals(8, program.instructions().size());
+ JsonObject move = ActionRegistry.toJson(program.instructions().get(2).action);
+ assertEquals("forward", move.get("direction").getAsString());
+ assertEquals(1.2F, move.get("blocks").getAsFloat());
+ assertEquals(90.0F,
+ ActionRegistry.toJson(program.instructions().get(3).action).get("degrees").getAsFloat());
+ }
+
+ @Test
+ void randomWaitDelaysWithinBounds() {
+ RandomWaitAction action = new RandomWaitAction(2, 5);
+ ActionContext ctx = new ActionContext(null, new TickScheduler(), -1);
+ for (int i = 0; i < 50; i++) {
+ int delay = action.execute(ctx).delayTicks();
+ assertTrue(delay >= 2 && delay <= 5);
+ }
+ }
+
+ @Test
+ void reportsErrorsWithLineNumbers() {
+ assertEquals(1, assertThrows(ScriptException.class,
+ () -> ScriptCompiler.compile("attack")).line());
+ assertEquals(2, assertThrows(ScriptException.class,
+ () -> ScriptCompiler.compile("when key(r)\nexplode")).line());
+ assertEquals(3, assertThrows(ScriptException.class,
+ () -> ScriptCompiler.compile("when key(r)\nattack\nif key_release(r) { use }")).line());
+ assertEquals(2, assertThrows(ScriptException.class,
+ () -> ScriptCompiler.compile("when key(r)\nuse_item(hotbarGate=sometimes)")).line());
+ assertEquals(3, assertThrows(ScriptException.class,
+ () -> ScriptCompiler.compile("when key(r)\nif riding {\nattack")).line());
+ assertThrows(ScriptException.class,
+ () -> ScriptCompiler.compile("when key(banana)\nattack"));
+ }
+}
diff --git a/src/test/java/com/kingjayan/keybinds/SerializationStressTest.java b/src/test/java/com/kingjayan/keybinds/SerializationStressTest.java
new file mode 100644
index 0000000..fe58adb
--- /dev/null
+++ b/src/test/java/com/kingjayan/keybinds/SerializationStressTest.java
@@ -0,0 +1,205 @@
+package com.kingjayan.keybinds;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
+import com.kingjayan.keybinds.core.action.ActionTypes;
+import com.kingjayan.keybinds.core.macro.Macro;
+import com.kingjayan.keybinds.core.trigger.TriggerContext;
+import com.kingjayan.keybinds.core.trigger.TriggerIndex;
+import com.kingjayan.keybinds.core.trigger.TriggerRegistry;
+import com.kingjayan.keybinds.core.trigger.TriggerTypes;
+import com.kingjayan.keybinds.serialize.MacroCodec;
+import com.kingjayan.keybinds.ui.editor.EditorSpecs;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SerializationStressTest {
+
+ @BeforeAll
+ static void bootstrap() {
+ ActionTypes.bootstrap();
+ TriggerTypes.bootstrap();
+ }
+
+ @Test
+ void fullCatalogRoundTripIsStable() {
+ JsonObject macroJson = new JsonObject();
+ macroJson.addProperty("id", UUID.randomUUID().toString());
+ macroJson.addProperty("name", "catalog");
+ macroJson.addProperty("enabled", true);
+ JsonObject trigger = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("any_of"));
+ JsonArray children = trigger.getAsJsonArray("children");
+ for (EditorSpecs.TypeSpec spec : EditorSpecs.triggerTypes()) {
+ if (spec.nesting() == EditorSpecs.Nesting.NONE) {
+ children.add(EditorSpecs.defaultJson(spec));
+ }
+ }
+ JsonObject not = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("not"));
+ not.add("child", EditorSpecs.defaultJson(EditorSpecs.triggerSpec("riding")));
+ children.add(not);
+ macroJson.add("trigger", trigger);
+ JsonArray actions = new JsonArray();
+ for (EditorSpecs.TypeSpec spec : EditorSpecs.actionTypes()) {
+ actions.add(EditorSpecs.defaultJson(spec));
+ }
+ macroJson.add("actions", actions);
+
+ Macro macro = MacroCodec.readMacro(macroJson);
+ assertNotNull(macro.trigger());
+ assertEquals(EditorSpecs.actionTypes().size(), macro.actions().size());
+ JsonObject written = MacroCodec.writeMacro(macro);
+ assertEquals(written, MacroCodec.writeMacro(MacroCodec.readMacro(written)));
+ }
+
+ @Test
+ void fiveHundredRandomMacrosRoundTrip() {
+ Random random = new Random(20260712);
+ List macros = new ArrayList<>();
+ for (int i = 0; i < 500; i++) {
+ JsonObject json = new JsonObject();
+ json.addProperty("id", new UUID(random.nextLong(), random.nextLong()).toString());
+ json.addProperty("name", "macro " + i);
+ json.addProperty("enabled", random.nextBoolean());
+ json.add("trigger", randomTriggerTree(random, 0));
+ JsonArray actions = new JsonArray();
+ int count = 1 + random.nextInt(10);
+ List actionSpecs = EditorSpecs.actionTypes();
+ for (int a = 0; a < count; a++) {
+ actions.add(randomize(EditorSpecs.defaultJson(
+ actionSpecs.get(random.nextInt(actionSpecs.size()))), random));
+ }
+ json.add("actions", actions);
+ macros.add(MacroCodec.readMacro(json));
+ }
+ JsonObject first = MacroCodec.write(macros);
+ List reloaded = MacroCodec.read(first);
+ assertEquals(500, reloaded.size());
+ assertEquals(first, MacroCodec.write(reloaded));
+
+ TriggerIndex index = new TriggerIndex();
+ long start = System.nanoTime();
+ index.rebuild(reloaded);
+ long rebuildMicros = (System.nanoTime() - start) / 1000;
+ assertTrue(rebuildMicros < 1_000_000, "index rebuild took " + rebuildMicros + "µs");
+ }
+
+ @Test
+ void malformedInputIsTolerated() {
+ JsonObject newer = new JsonObject();
+ newer.addProperty("formatVersion", MacroCodec.FORMAT_VERSION + 1);
+ assertTrue(MacroCodec.read(newer).isEmpty());
+
+ JsonObject root = new JsonObject();
+ JsonArray macros = new JsonArray();
+ macros.add(new JsonPrimitive("garbage"));
+ JsonObject bad = new JsonObject();
+ bad.addProperty("id", "not-a-uuid");
+ JsonObject badTrigger = new JsonObject();
+ badTrigger.addProperty("type", "does_not_exist");
+ bad.add("trigger", badTrigger);
+ JsonArray badActions = new JsonArray();
+ JsonObject badAction = new JsonObject();
+ badAction.addProperty("type", "also_missing");
+ badActions.add(badAction);
+ badActions.add(new JsonPrimitive(7));
+ bad.add("actions", badActions);
+ macros.add(bad);
+ root.add("macros", macros);
+
+ List read = MacroCodec.read(root);
+ assertEquals(1, read.size());
+ Macro macro = read.getFirst();
+ assertNotNull(macro.id());
+ assertEquals("Unnamed", macro.name());
+ assertTrue(macro.actions().isEmpty());
+ assertEquals(null, macro.trigger());
+ }
+
+ @Test
+ void indexKeepsNonKeyedTriggersAsCandidates() {
+ JsonObject keyed = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("key"));
+ keyed.addProperty("key", 65);
+ Macro keyMacro = macroWithTrigger(keyed);
+
+ JsonObject not = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("not"));
+ JsonObject notChild = EditorSpecs.defaultJson(EditorSpecs.triggerSpec("key"));
+ notChild.addProperty("key", 66);
+ not.add("child", notChild);
+ Macro notMacro = macroWithTrigger(not);
+
+ Macro conditionMacro = macroWithTrigger(
+ EditorSpecs.defaultJson(EditorSpecs.triggerSpec("condition")));
+
+ TriggerIndex index = new TriggerIndex();
+ index.rebuild(List.of(keyMacro, notMacro, conditionMacro));
+ List candidates = index.candidates(new TriggerContext());
+ assertTrue(candidates.contains(notMacro));
+ assertTrue(candidates.contains(conditionMacro));
+ assertFalse(candidates.contains(keyMacro));
+ }
+
+ private static Macro macroWithTrigger(JsonObject trigger) {
+ JsonObject json = new JsonObject();
+ json.addProperty("id", UUID.randomUUID().toString());
+ json.addProperty("name", "test");
+ json.add("trigger", trigger);
+ json.add("actions", new JsonArray());
+ return MacroCodec.readMacro(json);
+ }
+
+ private static JsonObject randomTriggerTree(Random random, int depth) {
+ List specs = EditorSpecs.triggerTypes();
+ EditorSpecs.TypeSpec spec = specs.get(random.nextInt(specs.size()));
+ if (depth >= 3 && spec.nesting() != EditorSpecs.Nesting.NONE) {
+ spec = EditorSpecs.triggerSpec("key");
+ }
+ JsonObject json = randomize(EditorSpecs.defaultJson(spec), random);
+ if (spec.nesting() == EditorSpecs.Nesting.MANY) {
+ JsonArray children = json.getAsJsonArray("children");
+ int count = 1 + random.nextInt(3);
+ for (int i = 0; i < count; i++) {
+ children.add(randomTriggerTree(random, depth + 1));
+ }
+ } else if (spec.nesting() == EditorSpecs.Nesting.ONE) {
+ json.add("child", randomTriggerTree(random, depth + 1));
+ }
+ return json;
+ }
+
+ private static JsonObject randomize(JsonObject json, Random random) {
+ String type = json.get("type").getAsString();
+ EditorSpecs.TypeSpec spec = EditorSpecs.actionSpec(type) != null
+ ? EditorSpecs.actionSpec(type) : EditorSpecs.triggerSpec(type);
+ for (EditorSpecs.ParamSpec param : spec.params()) {
+ switch (param.kind()) {
+ case INT -> json.addProperty(param.key(), random.nextInt(100));
+ case KEY, MOUSE -> json.addProperty(param.key(), random.nextInt(120));
+ case BOOL -> json.addProperty(param.key(), random.nextBoolean());
+ case STRING -> json.addProperty(param.key(),
+ random.nextBoolean() ? "" : "minecraft:stick");
+ case ENUM -> json.addProperty(param.key(),
+ param.options().get(random.nextInt(param.options().size())));
+ case STRING_LIST -> {
+ JsonArray items = new JsonArray();
+ if (random.nextBoolean()) {
+ items.add("minecraft:mace");
+ items.add("minecraft:wind_charge");
+ }
+ json.add(param.key(), items);
+ }
+ }
+ }
+ return json;
+ }
+}
diff --git a/todo.txt b/todo.txt
deleted file mode 100644
index c73e202..0000000
--- a/todo.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-lunge swap
-smart attribute swapping
-toggle + item clicking > keybinds
\ No newline at end of file