From 4574f3ed63999be03db2e7e31316cc18ec0e2304 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 8 May 2026 00:03:09 +0000 Subject: [PATCH 1/5] Clean up BetterHUD settings menu Reorganize the cloth-config UI for a less cluttered, more discoverable settings screen: - Group each module's options into 'Position' and 'Appearance' sub-categories instead of a flat list of seven controls. - Replace the freeform string dropdown for Orientation with a cycling Selector and human-friendly labels (Top Left, Top Right, ...). - Replace the unbounded Custom X / Custom Y integer fields with sliders that respect the existing 0-100 bounds and render as percentages. - Hide Anchor when Custom Position is on, and hide Custom X/Y when it is off, so only the relevant controls are interactive. - Add tooltips to every control explaining what it does. - Show the Enable toggle as a top-level Enabled/Disabled switch with a clearer 'Enable ' label. Co-authored-by: Dominic Seung --- src/main/java/dsns/betterhud/ModMenu.java | 306 +++++++++++++++------- 1 file changed, 216 insertions(+), 90 deletions(-) diff --git a/src/main/java/dsns/betterhud/ModMenu.java b/src/main/java/dsns/betterhud/ModMenu.java index 70400af..aad4ac4 100644 --- a/src/main/java/dsns/betterhud/ModMenu.java +++ b/src/main/java/dsns/betterhud/ModMenu.java @@ -3,17 +3,31 @@ import com.terraformersmc.modmenu.api.ConfigScreenFactory; import com.terraformersmc.modmenu.api.ModMenuApi; import dsns.betterhud.util.BaseMod; +import dsns.betterhud.util.ModSettings; import dsns.betterhud.util.Setting; -import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.Map; import me.shedaniel.clothconfig2.api.ConfigBuilder; import me.shedaniel.clothconfig2.api.ConfigCategory; import me.shedaniel.clothconfig2.api.ConfigEntryBuilder; +import me.shedaniel.clothconfig2.api.Requirement; +import me.shedaniel.clothconfig2.gui.entries.BooleanListEntry; +import me.shedaniel.clothconfig2.impl.builders.SubCategoryBuilder; import net.fabricmc.loader.api.FabricLoader; import net.minecraft.network.chat.Component; public class ModMenu implements ModMenuApi { + private static final Map ORIENTATION_LABELS; + + static { + ORIENTATION_LABELS = new LinkedHashMap<>(); + ORIENTATION_LABELS.put("top-left", "Top Left"); + ORIENTATION_LABELS.put("top-right", "Top Right"); + ORIENTATION_LABELS.put("bottom-left", "Bottom Left"); + ORIENTATION_LABELS.put("bottom-right", "Bottom Right"); + } + @Override public ConfigScreenFactory getModConfigScreenFactory() { if (!FabricLoader.getInstance().isModLoaded("cloth-config2")) { @@ -24,7 +38,6 @@ public ConfigScreenFactory getModConfigScreenFactory() { .setParentScreen(parent) .setTitle(Component.literal("BetterHUD Settings")); - // same as builder.setSavingRunnable(() -> Config.serialize()); builder.setSavingRunnable(Config::serialize); ConfigEntryBuilder entryBuilder = builder.entryBuilder(); @@ -33,97 +46,210 @@ public ConfigScreenFactory getModConfigScreenFactory() { ConfigCategory category = builder.getOrCreateCategory( Component.literal(mod.getModID()) ); - - for (Map.Entry entry : mod - .getModSettings() - .getSettings() - .entrySet()) { - String key = entry.getKey(); - Setting setting = entry.getValue(); - - if (setting.getType().equals("boolean")) { - category.addEntry( - entryBuilder - .startBooleanToggle( - Component.literal(key), - setting.getBooleanValue() - ) - .setDefaultValue( - Boolean.valueOf(setting.getDefaultValue()) - ) - .setSaveConsumer(value -> - setting.setValue(String.valueOf(value)) - ) - .build() - ); - } else if (setting.getType().equals("string")) { - category.addEntry( - entryBuilder - .startStringDropdownMenu( - Component.literal(key), - setting.getStringValue() - ) - .setSelections( - Arrays.asList(setting.getPossibleValues()) - ) - .setDefaultValue(setting.getDefaultValue()) - .setSaveConsumer(value -> - setting.setValue(value) - ) - .build() - ); - } else if (setting.getType().equals("integer")) { - category.addEntry( - entryBuilder - .startIntField( - Component.literal(key), - setting.getIntValue() - ) - .setDefaultValue( - Integer.parseInt(setting.getDefaultValue()) - ) - .setSaveConsumer(value -> - setting.setValue(String.valueOf(value)) - ) - .build() - ); - } else if (setting.getType().equals("double")) { - category.addEntry( - entryBuilder - .startDoubleField( - Component.literal(key), - setting.getDoubleValue() - ) - .setDefaultValue( - Double.parseDouble( - setting.getDefaultValue() - ) - ) - .setSaveConsumer(value -> - setting.setValue(String.valueOf(value)) - ) - .build() - ); - } else if (setting.getType().equals("color")) { - category.addEntry( - entryBuilder - .startAlphaColorField( - Component.literal(key), - setting.getColorValue() - ) - .setDefaultValue( - Integer.parseInt(setting.getDefaultValue()) - ) - .setSaveConsumer(value -> - setting.setValue(String.valueOf(value)) - ) - .build() - ); - } - } + buildModCategory(category, entryBuilder, mod); } return builder.build(); }; } + + private void buildModCategory( + ConfigCategory category, + ConfigEntryBuilder entryBuilder, + BaseMod mod + ) { + ModSettings settings = mod.getModSettings(); + String name = mod.getModID(); + + Setting enabled = settings.getSetting("Enabled"); + BooleanListEntry enabledEntry = entryBuilder + .startBooleanToggle( + Component.literal("Enable " + name), + enabled.getBooleanValue() + ) + .setDefaultValue(Boolean.parseBoolean(enabled.getDefaultValue())) + .setTooltip( + Component.literal( + "Show the " + name + " module on the HUD." + ) + ) + .setYesNoTextSupplier(value -> + Component.literal(value ? "Enabled" : "Disabled") + ) + .setSaveConsumer(value -> + enabled.setValue(String.valueOf(value)) + ) + .build(); + category.addEntry(enabledEntry); + + Setting customPosition = settings.getSetting("Custom Position"); + BooleanListEntry customPositionEntry = entryBuilder + .startBooleanToggle( + Component.literal("Use Custom Position"), + customPosition.getBooleanValue() + ) + .setDefaultValue( + Boolean.parseBoolean(customPosition.getDefaultValue()) + ) + .setTooltip( + Component.literal( + "Override the screen anchor and place this module at an exact location." + ) + ) + .setSaveConsumer(value -> + customPosition.setValue(String.valueOf(value)) + ) + .build(); + + SubCategoryBuilder positionGroup = entryBuilder + .startSubCategory(Component.literal("Position")) + .setExpanded(true) + .setTooltip( + Component.literal( + "Where the module is drawn on the screen." + ) + ); + + Setting orientation = settings.getSetting("Orientation"); + positionGroup.add( + entryBuilder + .startSelector( + Component.literal("Anchor"), + orientation.getPossibleValues(), + orientation.getStringValue() + ) + .setDefaultValue(orientation.getDefaultValue()) + .setNameProvider(value -> + Component.literal(prettyOrientation(value)) + ) + .setTooltip( + Component.literal( + "Which corner of the screen the module is anchored to." + ) + ) + .setRequirement(Requirement.isFalse(customPositionEntry)) + .setSaveConsumer(value -> orientation.setValue(value)) + .build() + ); + + positionGroup.add(customPositionEntry); + + Setting customX = settings.getSetting("Custom X"); + positionGroup.add( + entryBuilder + .startIntSlider( + Component.literal("Custom X"), + customX.getIntValue(), + parseBound(customX, 0, 0), + parseBound(customX, 1, 100) + ) + .setDefaultValue(Integer.parseInt(customX.getDefaultValue())) + .setTextGetter(value -> Component.literal(value + "%")) + .setTooltip( + Component.literal( + "Horizontal position as a percentage of screen width (0% = left edge, 100% = right edge)." + ) + ) + .setRequirement(Requirement.isTrue(customPositionEntry)) + .setSaveConsumer(value -> + customX.setValue(String.valueOf(value)) + ) + .build() + ); + + Setting customY = settings.getSetting("Custom Y"); + positionGroup.add( + entryBuilder + .startIntSlider( + Component.literal("Custom Y"), + customY.getIntValue(), + parseBound(customY, 0, 0), + parseBound(customY, 1, 100) + ) + .setDefaultValue(Integer.parseInt(customY.getDefaultValue())) + .setTextGetter(value -> Component.literal(value + "%")) + .setTooltip( + Component.literal( + "Vertical position as a percentage of screen height (0% = top edge, 100% = bottom edge)." + ) + ) + .setRequirement(Requirement.isTrue(customPositionEntry)) + .setSaveConsumer(value -> + customY.setValue(String.valueOf(value)) + ) + .build() + ); + + category.addEntry(positionGroup.build()); + + SubCategoryBuilder appearanceGroup = entryBuilder + .startSubCategory(Component.literal("Appearance")) + .setExpanded(false) + .setTooltip( + Component.literal( + "Colors used to draw the module." + ) + ); + + Setting textColor = settings.getSetting("Text Color"); + appearanceGroup.add( + entryBuilder + .startAlphaColorField( + Component.literal("Text Color"), + textColor.getColorValue() + ) + .setDefaultValue( + Integer.parseInt(textColor.getDefaultValue()) + ) + .setTooltip( + Component.literal( + "Color of the displayed text (with alpha)." + ) + ) + .setSaveConsumer(value -> + textColor.setValue(String.valueOf(value)) + ) + .build() + ); + + Setting backgroundColor = settings.getSetting("Background Color"); + appearanceGroup.add( + entryBuilder + .startAlphaColorField( + Component.literal("Background Color"), + backgroundColor.getColorValue() + ) + .setDefaultValue( + Integer.parseInt(backgroundColor.getDefaultValue()) + ) + .setTooltip( + Component.literal( + "Color of the rectangle drawn behind the text. Set alpha to 0 for no background." + ) + ) + .setSaveConsumer(value -> + backgroundColor.setValue(String.valueOf(value)) + ) + .build() + ); + + category.addEntry(appearanceGroup.build()); + } + + private static String prettyOrientation(String raw) { + String pretty = ORIENTATION_LABELS.get(raw); + return pretty != null ? pretty : raw; + } + + private static int parseBound(Setting setting, int index, int fallback) { + String[] possible = setting.getPossibleValues(); + if (possible == null || possible.length <= index) { + return fallback; + } + try { + return Integer.parseInt(possible[index]); + } catch (NumberFormatException e) { + return fallback; + } + } } From c793dbf9ab767bda8e25cf46621ee4b6b12431d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 16:14:33 +0000 Subject: [PATCH 2/5] Add Scale option from main to the reworked config UI The merge from main brought in the new per-module Scale setting, but the reworked ModMenu builds its controls explicitly rather than iterating over settings, so Scale would have been missing from the config screen. Expose it as a double field in the Appearance group with bounds read from the setting definition. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lmeg4FeVuuYgrKUT7sPnPu --- src/main/java/dsns/betterhud/ModMenu.java | 39 ++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/main/java/dsns/betterhud/ModMenu.java b/src/main/java/dsns/betterhud/ModMenu.java index aad4ac4..653e425 100644 --- a/src/main/java/dsns/betterhud/ModMenu.java +++ b/src/main/java/dsns/betterhud/ModMenu.java @@ -187,10 +187,31 @@ private void buildModCategory( .setExpanded(false) .setTooltip( Component.literal( - "Colors used to draw the module." + "Size and colors used to draw the module." ) ); + Setting scale = settings.getSetting("Scale"); + appearanceGroup.add( + entryBuilder + .startDoubleField( + Component.literal("Scale"), + scale.getDoubleValue() + ) + .setDefaultValue(Double.parseDouble(scale.getDefaultValue())) + .setMin(parseDoubleBound(scale, 0, 0.1)) + .setMax(parseDoubleBound(scale, 1, 10.0)) + .setTooltip( + Component.literal( + "Size multiplier applied to the module (1.0 = default size)." + ) + ) + .setSaveConsumer(value -> + scale.setValue(String.valueOf(value)) + ) + .build() + ); + Setting textColor = settings.getSetting("Text Color"); appearanceGroup.add( entryBuilder @@ -252,4 +273,20 @@ private static int parseBound(Setting setting, int index, int fallback) { return fallback; } } + + private static double parseDoubleBound( + Setting setting, + int index, + double fallback + ) { + String[] possible = setting.getPossibleValues(); + if (possible == null || possible.length <= index) { + return fallback; + } + try { + return Double.parseDouble(possible[index]); + } catch (NumberFormatException e) { + return fallback; + } + } } From ed70b918aac778ea31a1889c5ac21dfedfe74efe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Jul 2026 03:37:26 +0000 Subject: [PATCH 3/5] Merge main and restore module-specific settings in config UI Bring in the Stonecutter multi-version build system and concurrent launch-test CI from main. Re-add the Options sub-category for non-standard per-module settings (e.g. Coordinates Decimal) that was lost during the earlier Scale integration. Co-authored-by: Dominic Seung --- src/main/java/dsns/betterhud/ModMenu.java | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/main/java/dsns/betterhud/ModMenu.java b/src/main/java/dsns/betterhud/ModMenu.java index 653e425..787e4ec 100644 --- a/src/main/java/dsns/betterhud/ModMenu.java +++ b/src/main/java/dsns/betterhud/ModMenu.java @@ -5,8 +5,11 @@ import dsns.betterhud.util.BaseMod; import dsns.betterhud.util.ModSettings; import dsns.betterhud.util.Setting; +import java.util.Arrays; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import me.shedaniel.clothconfig2.api.AbstractConfigListEntry; import me.shedaniel.clothconfig2.api.ConfigBuilder; import me.shedaniel.clothconfig2.api.ConfigCategory; import me.shedaniel.clothconfig2.api.ConfigEntryBuilder; @@ -19,6 +22,16 @@ public class ModMenu implements ModMenuApi { private static final Map ORIENTATION_LABELS; + private static final List STANDARD_KEYS = Arrays.asList( + "Enabled", + "Orientation", + "Custom Position", + "Custom X", + "Custom Y", + "Scale", + "Text Color", + "Background Color" + ); static { ORIENTATION_LABELS = new LinkedHashMap<>(); @@ -82,6 +95,36 @@ private void buildModCategory( .build(); category.addEntry(enabledEntry); + SubCategoryBuilder optionsGroup = null; + for (Map.Entry entry : settings + .getSettings() + .entrySet()) { + if (STANDARD_KEYS.contains(entry.getKey())) { + continue; + } + if (optionsGroup == null) { + optionsGroup = entryBuilder + .startSubCategory(Component.literal("Options")) + .setExpanded(true) + .setTooltip( + Component.literal( + "Settings specific to the " + name + " module." + ) + ); + } + AbstractConfigListEntry optionEntry = buildGenericEntry( + entryBuilder, + entry.getKey(), + entry.getValue() + ); + if (optionEntry != null) { + optionsGroup.add(optionEntry); + } + } + if (optionsGroup != null) { + category.addEntry(optionsGroup.build()); + } + Setting customPosition = settings.getSetting("Custom Position"); BooleanListEntry customPositionEntry = entryBuilder .startBooleanToggle( @@ -257,6 +300,70 @@ private void buildModCategory( category.addEntry(appearanceGroup.build()); } + private AbstractConfigListEntry buildGenericEntry( + ConfigEntryBuilder entryBuilder, + String key, + Setting setting + ) { + Component label = Component.literal(key); + switch (setting.getType()) { + case "boolean": + return entryBuilder + .startBooleanToggle(label, setting.getBooleanValue()) + .setDefaultValue( + Boolean.parseBoolean(setting.getDefaultValue()) + ) + .setSaveConsumer(value -> + setting.setValue(String.valueOf(value)) + ) + .build(); + case "string": + return entryBuilder + .startSelector( + label, + setting.getPossibleValues(), + setting.getStringValue() + ) + .setDefaultValue(setting.getDefaultValue()) + .setSaveConsumer(value -> setting.setValue(value)) + .build(); + case "integer": + return entryBuilder + .startIntField(label, setting.getIntValue()) + .setMin(parseBound(setting, 0, Integer.MIN_VALUE)) + .setMax(parseBound(setting, 1, Integer.MAX_VALUE)) + .setDefaultValue( + Integer.parseInt(setting.getDefaultValue()) + ) + .setSaveConsumer(value -> + setting.setValue(String.valueOf(value)) + ) + .build(); + case "double": + return entryBuilder + .startDoubleField(label, setting.getDoubleValue()) + .setDefaultValue( + Double.parseDouble(setting.getDefaultValue()) + ) + .setSaveConsumer(value -> + setting.setValue(String.valueOf(value)) + ) + .build(); + case "color": + return entryBuilder + .startAlphaColorField(label, setting.getColorValue()) + .setDefaultValue( + Integer.parseInt(setting.getDefaultValue()) + ) + .setSaveConsumer(value -> + setting.setValue(String.valueOf(value)) + ) + .build(); + default: + return null; + } + } + private static String prettyOrientation(String raw) { String pretty = ORIENTATION_LABELS.get(raw); return pretty != null ? pretty : raw; From 26d7e7894accf66a272847823a87c5a2305cfc45 Mon Sep 17 00:00:00 2001 From: Dominic Seung Date: Tue, 7 Jul 2026 18:23:40 -0700 Subject: [PATCH 4/5] update settings menu cleanup (#28) * Update Minecraft dependency version range (#24) * Update requirements formatting in README.md * Add automated publish workflow for Modrinth and CurseForge (#26) * Add on-demand publish workflow for Modrinth and CurseForge Run the "publish" workflow from the Actions tab to release the current mod_version: it builds every Stonecutter variant and uploads each release jar as its own version on Modrinth (betterhudfabric) and CurseForge (project 1375095) via mc-publish, selecting exactly the Minecraft versions each jar covers from supported-versions.json. - generate-matrices.sh gains a `publish` matrix (variant, java, per-jar game version list, and a human-readable range for the release name) - inputs: changelog, release channel (release/beta/alpha), and platform selection (both/modrinth/curseforge) - uploads run one variant at a time, oldest first, so the newest Minecraft build ends up as the most recent upload; a failure stops the remaining uploads - fails fast if a selected platform's token secret (MODRINTH_TOKEN / CURSEFORGE_TOKEN) is missing, since mc-publish would otherwise skip it silently - README release section updated accordingly Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gw2SqwSvrHSQcDDXSH2Vn9 * Match existing release title format ("Minecraft" in the range) Titles now read e.g. "BetterHUD 2.1.1 (Minecraft 1.21-1.21.5)", matching the convention of the existing uploads on Modrinth and CurseForge. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Gw2SqwSvrHSQcDDXSH2Vn9 --------- Co-authored-by: Claude --------- Co-authored-by: Claude --- .github/scripts/generate-matrices.sh | 22 +++- .github/workflows/publish.yml | 147 +++++++++++++++++++++++++++ README.md | 27 +++-- versions/1.21/gradle.properties | 2 +- 4 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/publish.yml diff --git a/.github/scripts/generate-matrices.sh b/.github/scripts/generate-matrices.sh index ff6a526..ddf6d18 100755 --- a/.github/scripts/generate-matrices.sh +++ b/.github/scripts/generate-matrices.sh @@ -1,10 +1,14 @@ #!/usr/bin/env bash # Derives the CI job matrices from supported-versions.json (the single source # of truth for supported Minecraft versions) and writes them to GITHUB_OUTPUT: -# - build: one entry per build variant -> { mc, java } -# - launch: one entry per launchable version -> { mc, java, pregen_world } +# - build: one entry per build variant -> { mc, java } +# - launch: one entry per launchable version -> { mc, java, pregen_world } +# - publish: one entry per build variant -> { mc, java, game_versions, range } # pregen_world marks versions whose Fabric API has no client gametest module; # their launch test needs a pre-generated world (see generate-test-world.sh). +# game_versions is the newline-separated list of Minecraft versions the +# variant's jar covers (fed to mc-publish), range is its human-readable form +# for the release name (e.g. "1.21-1.21.5"). set -euo pipefail JSON=supported-versions.json @@ -22,13 +26,23 @@ while IFS=$'\t' read -r mc variant gametest; do done < <(jq -r 'to_entries[] | [.key, .value.variant, (.value.clientGametest | tostring)] | @tsv' "$JSON") build="[]" +publish="[]" while read -r variant; do java="$(java_for_variant "$variant")" build="$(jq -c --arg mc "$variant" --arg java "$java" \ '. + [{mc: $mc, java: $java}]' <<<"$build")" + game_versions="$(jq -r --arg v "$variant" \ + '[to_entries[] | select(.value.variant == $v) | .key] | join("\n")' "$JSON")" + range="$(jq -r --arg v "$variant" \ + '[to_entries[] | select(.value.variant == $v) | .key] + | if length == 1 then .[0] else "\(first)-\(last)" end' "$JSON")" + publish="$(jq -c --arg mc "$variant" --arg java "$java" --arg gv "$game_versions" --arg range "$range" \ + '. + [{mc: $mc, java: $java, game_versions: $gv, range: $range}]' <<<"$publish")" done < <(jq -r '[.[].variant] | reduce .[] as $v ([]; if index($v) then . else . + [$v] end) | .[]' "$JSON") echo "build=$build" >> "$GITHUB_OUTPUT" echo "launch=$launch" >> "$GITHUB_OUTPUT" -echo "build matrix: $build" -echo "launch matrix: $launch" +echo "publish=$publish" >> "$GITHUB_OUTPUT" +echo "build matrix: $build" +echo "launch matrix: $launch" +echo "publish matrix: $publish" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..9ab1350 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,147 @@ +# Publishes the mod to Modrinth and CurseForge on demand: bump `mod_version` +# in gradle.properties, push, then run this workflow from the Actions tab. +# It builds every Stonecutter variant and uploads each release jar as its own +# version on both platforms, selecting exactly the Minecraft versions that jar +# covers — driven by supported-versions.json, the same single source of truth +# the build and launch-test matrices use. +# +# Required repository secrets (Settings -> Secrets and variables -> Actions): +# - MODRINTH_TOKEN: Modrinth PAT with the "Create versions" scope +# (https://modrinth.com/settings/pats) +# - CURSEFORGE_TOKEN: CurseForge API token +# (https://legacy.curseforge.com/account/api-tokens) +# +# This workflow only builds; correctness on every supported Minecraft version +# is already covered by the launch tests that run on every push (build.yml), +# so make sure the commit you release from is green there first. + +name: publish +on: + workflow_dispatch: + inputs: + changelog: + description: 'Changelog (Markdown, shown on both platforms)' + required: true + type: string + version_type: + description: 'Release channel' + required: true + type: choice + default: release + options: + - release + - beta + - alpha + platforms: + description: 'Where to publish' + required: true + type: choice + default: both + options: + - both + - modrinth + - curseforge + +permissions: + contents: read + +# Never let two release runs interleave their uploads. +concurrency: + group: publish + cancel-in-progress: false + +jobs: + matrices: + name: compute publish matrix + runs-on: ubuntu-24.04 + outputs: + publish: ${{ steps.gen.outputs.publish }} + mod_version: ${{ steps.version.outputs.mod_version }} + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: generate matrices from supported-versions.json + id: gen + run: bash .github/scripts/generate-matrices.sh + + - name: read mod version from gradle.properties + id: version + run: | + version="$(grep -oP '^mod_version=\K.+' gradle.properties)" + echo "mod_version=$version" >> "$GITHUB_OUTPUT" + echo "Releasing BetterHUD $version" + + - name: check the required tokens are configured + # mc-publish silently skips a platform whose token is empty, which + # would make a run "succeed" while publishing nothing — fail early + # instead if a selected platform has no token. + env: + PLATFORMS: ${{ inputs.platforms }} + MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} + CURSEFORGE_TOKEN: ${{ secrets.CURSEFORGE_TOKEN }} + run: | + if [ "$PLATFORMS" != "curseforge" ] && [ -z "$MODRINTH_TOKEN" ]; then + echo "::error::MODRINTH_TOKEN secret is not set"; exit 1 + fi + if [ "$PLATFORMS" != "modrinth" ] && [ -z "$CURSEFORGE_TOKEN" ]; then + echo "::error::CURSEFORGE_TOKEN secret is not set"; exit 1 + fi + + publish: + name: publish mc${{ matrix.mc }} + runs-on: ubuntu-24.04 + needs: matrices + strategy: + # Publish one variant at a time, oldest first (supported-versions.json + # order), so the newest-Minecraft version ends up as the most recently + # uploaded — and a failure stops before uploading the rest. + max-parallel: 1 + fail-fast: true + matrix: + include: ${{ fromJson(needs.matrices.outputs.publish) }} + steps: + - name: checkout repository + uses: actions/checkout@v6 + + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v6 + + - name: setup jdk ${{ matrix.java }} + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.java }} + distribution: 'microsoft' + + - name: make gradle wrapper executable + run: chmod +x ./gradlew + + - name: build :${{ matrix.mc }}:build + run: ./gradlew :${{ matrix.mc }}:build --stacktrace + + - name: publish to modrinth and curseforge + uses: Kir-Antipov/mc-publish@v3.3 + with: + # An empty token makes mc-publish skip that platform, which is how + # the "platforms" input narrows the run to a single site. + modrinth-id: betterhudfabric + modrinth-token: ${{ inputs.platforms != 'curseforge' && secrets.MODRINTH_TOKEN || '' }} + curseforge-id: 1375095 + curseforge-token: ${{ inputs.platforms != 'modrinth' && secrets.CURSEFORGE_TOKEN || '' }} + + files: versions/${{ matrix.mc }}/build/libs/!(*-@(dev|sources|javadoc)).jar + name: BetterHUD ${{ needs.matrices.outputs.mod_version }} (Minecraft ${{ matrix.range }}) + version: ${{ needs.matrices.outputs.mod_version }}+mc${{ matrix.mc }} + version-type: ${{ inputs.version_type }} + changelog: ${{ inputs.changelog }} + + loaders: fabric + game-versions: ${{ matrix.game_versions }} + java: ${{ matrix.java }} + dependencies: | + fabric-api(required) + cloth-config(required) + modmenu(required) + + retry-attempts: 2 + retry-delay: 10000 diff --git a/README.md b/README.md index 04533a2..48cb519 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A simple HUD with essential information easily accessible for Minecraft Fabric. -[Cloth Config](https://modrinth.com/mod/cloth-config), [Mod Menu](https://modrinth.com/mod/modmenu), and [Fabric API](https://modrinth.com/mod/fabric-api) are also required. +Requires [Cloth Config](https://modrinth.com/mod/cloth-config), [Mod Menu](https://modrinth.com/mod/modmenu), and [Fabric API](https://modrinth.com/mod/fabric-api). ![Picture of HUD](https://cdn.modrinth.com/data/cached_images/4dd33c9ed510e100af5cc442eb93092624856ba5.png) @@ -43,7 +43,8 @@ truth for every supported Minecraft version. It drives: under XVFB, enters a survival world with the HUD active, screenshots it, and fails on any crash; the screenshots are posted as a grid on the PR), - the per-version runtime mods used by those launch tests, -- the Modrinth upload guide (see below). +- the publish workflow's per-jar Minecraft version lists and the manual + Modrinth upload guide (see below). ### Adding a new Minecraft version @@ -64,9 +65,21 @@ truth for every supported Minecraft version. It drives: To launch-test one version locally: `./gradlew :launchtest:runProductionClientGameTest -PtestMcVersion=` -### Releasing to Modrinth +### Releasing to Modrinth and CurseForge -Run `./gradlew modrinthBundle` (or download the `modrinth-upload` artifact -from any CI run). It produces `build/modrinth/` containing every release jar -plus `UPLOAD.md`, which lists exactly which Minecraft versions to select for -each jar when creating the Modrinth versions. +Bump `mod_version` in `gradle.properties`, push, wait for CI to go green, +then run the **publish** workflow from the Actions tab (Run workflow). It +builds every variant and uploads each release jar as its own version on +Modrinth and CurseForge, selecting exactly the Minecraft versions that jar +covers (from `supported-versions.json`). The workflow asks for a changelog, +a release channel (release/beta/alpha), and which platforms to publish to. + +It needs two repository secrets: `MODRINTH_TOKEN` (a +[Modrinth PAT](https://modrinth.com/settings/pats) with the "Create versions" +scope) and `CURSEFORGE_TOKEN` (a +[CurseForge API token](https://legacy.curseforge.com/account/api-tokens)). + +For a manual upload instead, run `./gradlew modrinthBundle` (or download the +`modrinth-upload` artifact from any CI run). It produces `build/modrinth/` +containing every release jar plus `UPLOAD.md`, which lists exactly which +Minecraft versions to select for each jar. diff --git a/versions/1.21/gradle.properties b/versions/1.21/gradle.properties index 7e06a69..17a5ecb 100644 --- a/versions/1.21/gradle.properties +++ b/versions/1.21/gradle.properties @@ -5,7 +5,7 @@ loader_version=0.17.3 java_version=21 # fabric.mod.json range -mc_dep=~1.21 +mc_dep=>=1.21 <=1.21.5 loader_dep=>=0.15.11 # Fabric API + ecosystem (compile-time versions for this variant; the launch From a0e9490ec62243a41039fd72677318fcca2456c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 02:52:44 +0000 Subject: [PATCH 5/5] Open BetterHUD settings through Mod Menu in the launch tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the per-version client launch tests so that, after the survival world check, the test opens Mod Menu's mod list and then BetterHUD's config screen through Mod Menu's registered factory — the same path a player takes via the Configure button — and screenshots both. This end-to-end exercises the reworked cloth-config settings screen on every supported Minecraft version with that version's exact Mod Menu build. - gametest flavours (1.21.4+): open ModsScreen and the config screen after leaving the world, with mods-menu and settings screenshots. - fallback flavour (1.21-1.21.3): drive the same screens from the tick state machine while in the pre-generated world. - launchtest/build.gradle: compile against the per-version Mod Menu already launched via productionRuntimeMods. - CI: fail a launch job when the settings screenshot is missing, and show survival + settings screenshots per version in the PR comment; mod-list screenshots stay in the run artifacts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lmeg4FeVuuYgrKUT7sPnPu --- .github/scripts/publish-screenshots.sh | 5 ++- .github/workflows/build.yml | 20 ++++++---- launchtest/build.gradle | 4 ++ .../betterhud/gametest/LaunchTestClient.java | 37 ++++++++++++++++--- .../src/fallback/resources/fabric.mod.json | 3 +- .../gametest/BetterHudClientGameTest.java | 20 ++++++++++ .../gametest/BetterHudClientGameTest.java | 20 ++++++++++ src/gametest/resources/fabric.mod.json | 3 +- 8 files changed, 95 insertions(+), 17 deletions(-) diff --git a/.github/scripts/publish-screenshots.sh b/.github/scripts/publish-screenshots.sh index cf77ee5..26e7afb 100755 --- a/.github/scripts/publish-screenshots.sh +++ b/.github/scripts/publish-screenshots.sh @@ -5,7 +5,8 @@ # URLs) in a PR comment and in the job summary. # # Leaves the normalized screenshots in screenshots-publish/ for the comment -# step: /mc--survival.png and /mc--title.png. +# step: /mc--survival.png, /mc--title.png, +# /mc--mods.png and /mc--settings.png. # # Required env: GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA set -euo pipefail @@ -34,7 +35,7 @@ mkdir -p "$PUBLISH" for dir in shots/*/; do mc="$(basename "$dir")" - for kind in survival-world title-screen; do + for kind in survival-world title-screen mods-menu settings; do src="$(find "$dir" -name "*betterhud-$kind.png" -print -quit)" if [ -n "$src" ]; then cp "$src" "$PUBLISH/mc-$mc-${kind%%-*}.png" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 20256d5..d447fb8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -114,6 +114,9 @@ jobs: - name: check survival world screenshot was taken run: test -n "$(find launchtest/run/screenshots -name '*betterhud-survival-world.png' -print -quit 2>/dev/null)" + - name: check settings screen screenshot was taken + run: test -n "$(find launchtest/run/screenshots -name '*betterhud-settings.png' -print -quit 2>/dev/null)" + - name: upload screenshots # Consumed by the screenshot-report job below. if: always() @@ -155,13 +158,14 @@ jobs: const dir = `screenshots-publish/${context.sha}`; const base = `https://raw.githubusercontent.com/${context.repo.owner}/${context.repo.repo}/ci-screenshots/${context.sha}`; - const cells = versions.map(v => { - const file = `mc-${v}-survival.png`; - const ok = fs.existsSync(`${dir}/${file}`); - return ok - ? `${v}
BetterHUD on Minecraft ${v}` - : `${v}
❌ no screenshot`; - }); + const shot = (v, kind, what) => { + const file = `mc-${v}-${kind}.png`; + return fs.existsSync(`${dir}/${file}`) + ? `${what} on Minecraft ${v}` + : `❌ no ${kind} screenshot`; + }; + const cells = versions.map(v => + `${v}
${shot(v, 'survival', 'BetterHUD')}
${shot(v, 'settings', 'BetterHUD settings')}`); let rows = ''; for (let i = 0; i < cells.length; i += 4) { rows += `${cells.slice(i, i + 4).join('')}\n`; @@ -170,7 +174,7 @@ jobs: const marker = ''; const body = [ marker, - `The game launched and entered a survival singleplayer world with the HUD active, on every supported Minecraft version, for \`${context.sha.slice(0, 7)}\`. A ❌ means that version's launch test did not produce a screenshot — check its job. Title-screen screenshots and game logs are in the run artifacts.`, + `The game launched, entered a survival singleplayer world with the HUD active, and opened the BetterHUD settings through Mod Menu, on every supported Minecraft version, for \`${context.sha.slice(0, 7)}\`. Each cell shows the survival world and the settings screen. A ❌ means that version's launch test did not produce that screenshot — check its job. Title-screen and mod-list screenshots and game logs are in the run artifacts.`, '', `\n${rows}
`, ].join('\n'); diff --git a/launchtest/build.gradle b/launchtest/build.gradle index 49d52e8..034d4be 100644 --- a/launchtest/build.gradle +++ b/launchtest/build.gradle @@ -93,12 +93,16 @@ dependencies { mappings loom.officialMojangMappings() modImplementation "net.fabricmc:fabric-loader:${loaderVersion}" modImplementation "net.fabricmc.fabric-api:fabric-api:${entry.fabricApi}" + // The test driver opens the BetterHUD settings through Mod Menu's mod + // list; compile against the exact Mod Menu launched for this version. + modCompileOnly("com.terraformersmc:modmenu:${entry.modmenu}") { transitive = false } if (entry.clientGametest) { modImplementation fabricApi.module('fabric-client-gametest-api-v1', entry.fabricApi) } } else { implementation "net.fabricmc:fabric-loader:${loaderVersion}" implementation "net.fabricmc.fabric-api:fabric-api:${entry.fabricApi}" + compileOnly("com.terraformersmc:modmenu:${entry.modmenu}") { transitive = false } implementation fabricApi.module('fabric-client-gametest-api-v1', entry.fabricApi) } diff --git a/launchtest/src/fallback/java/dsns/betterhud/gametest/LaunchTestClient.java b/launchtest/src/fallback/java/dsns/betterhud/gametest/LaunchTestClient.java index cb21937..9c670bb 100644 --- a/launchtest/src/fallback/java/dsns/betterhud/gametest/LaunchTestClient.java +++ b/launchtest/src/fallback/java/dsns/betterhud/gametest/LaunchTestClient.java @@ -1,9 +1,13 @@ package dsns.betterhud.gametest; +import com.terraformersmc.modmenu.ModMenu; +import com.terraformersmc.modmenu.gui.ModsScreen; + import net.fabricmc.api.ClientModInitializer; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.minecraft.client.Minecraft; import net.minecraft.client.Screenshot; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.TitleScreen; /** @@ -13,17 +17,23 @@ *

When the run is started with {@code --quickPlaySingleplayer ci-world} and * {@code -Dbetterhud.launchtest.expectWorld=true} (see the launchtest Gradle * project), the game joins the pre-generated survival world; once it has ticked - * long enough for chunks to render with the HUD active, this mod screenshots it - * and schedules a clean shutdown, making the client exit with code 0. Without a - * pre-generated world it screenshots the title screen and shuts down there - * instead. A crash anywhere on the way fails the run. + * long enough for chunks to render with the HUD active, this mod screenshots it, + * then opens the BetterHUD settings the way a player would (through Mod Menu's + * mod list), screenshots those screens too, and schedules a clean shutdown, + * making the client exit with code 0. Without a pre-generated world it + * screenshots the title screen and shuts down there instead. A crash anywhere + * on the way fails the run. */ public class LaunchTestClient implements ClientModInitializer { private static final boolean EXPECT_WORLD = Boolean.getBoolean("betterhud.launchtest.expectWorld"); // 200 ticks (10s) gives the software renderer on CI time to draw chunks. private static final int WORLD_SCREENSHOT_TICK = 200; - private static final int WORLD_STOP_TICK = 240; + private static final int MODS_SCREEN_TICK = 220; + private static final int MODS_SCREENSHOT_TICK = 240; + private static final int SETTINGS_SCREEN_TICK = 260; + private static final int SETTINGS_SCREENSHOT_TICK = 280; + private static final int WORLD_STOP_TICK = 300; private static final int TITLE_SCREENSHOT_TICK = 40; private static final int TITLE_STOP_TICK = 80; // 2400 ticks (2min) is plenty to join the pre-generated flat world; bail @@ -48,6 +58,14 @@ public void onInitializeClient() { worldTicks++; if (worldTicks == WORLD_SCREENSHOT_TICK) { takeScreenshot(client, "betterhud-survival-world"); + } else if (worldTicks == MODS_SCREEN_TICK) { + client.setScreen(new ModsScreen(client.screen)); + } else if (worldTicks == MODS_SCREENSHOT_TICK) { + takeScreenshot(client, "betterhud-mods-menu"); + } else if (worldTicks == SETTINGS_SCREEN_TICK) { + openBetterHudSettings(client); + } else if (worldTicks == SETTINGS_SCREENSHOT_TICK) { + takeScreenshot(client, "betterhud-settings"); } else if (worldTicks == WORLD_STOP_TICK) { client.stop(); } @@ -62,6 +80,15 @@ public void onInitializeClient() { }); } + private static void openBetterHudSettings(Minecraft client) { + Screen settings = ModMenu.getConfigScreen("betterhud", client.screen); + if (settings == null) { + System.err.println("betterhud launch test: Mod Menu has no config screen registered for betterhud"); + System.exit(1); + } + client.setScreen(settings); + } + private static void takeScreenshot(Minecraft client, String name) { Screenshot.grab(client.gameDirectory, name + ".png", client.getMainRenderTarget(), message -> {}); } diff --git a/launchtest/src/fallback/resources/fabric.mod.json b/launchtest/src/fallback/resources/fabric.mod.json index ec2bc93..15aaddb 100644 --- a/launchtest/src/fallback/resources/fabric.mod.json +++ b/launchtest/src/fallback/resources/fabric.mod.json @@ -11,6 +11,7 @@ }, "depends": { "betterhud": "*", - "fabric-lifecycle-events-v1": "*" + "fabric-lifecycle-events-v1": "*", + "modmenu": "*" } } diff --git a/launchtest/src/gametest-legacy/java/dsns/betterhud/gametest/BetterHudClientGameTest.java b/launchtest/src/gametest-legacy/java/dsns/betterhud/gametest/BetterHudClientGameTest.java index 071f168..21cca71 100644 --- a/launchtest/src/gametest-legacy/java/dsns/betterhud/gametest/BetterHudClientGameTest.java +++ b/launchtest/src/gametest-legacy/java/dsns/betterhud/gametest/BetterHudClientGameTest.java @@ -10,9 +10,13 @@ import java.nio.file.Files; import java.nio.file.Path; +import com.terraformersmc.modmenu.ModMenu; +import com.terraformersmc.modmenu.gui.ModsScreen; + import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; @SuppressWarnings("UnstableApiUsage") @@ -30,6 +34,22 @@ public void runTest(ClientGameTestContext context) { context.waitTicks(40); assertScreenshotSaved(context.takeScreenshot("betterhud-survival-world")); } + + // Back on the title screen: open the BetterHUD settings the way a + // player would, through Mod Menu's mod list. + context.runOnClient(client -> client.setScreen(new ModsScreen(client.screen))); + context.waitTicks(10); + assertScreenshotSaved(context.takeScreenshot("betterhud-mods-menu")); + + context.runOnClient(client -> { + Screen settings = ModMenu.getConfigScreen("betterhud", client.screen); + if (settings == null) { + throw new AssertionError("Mod Menu has no config screen registered for betterhud"); + } + client.setScreen(settings); + }); + context.waitTicks(10); + assertScreenshotSaved(context.takeScreenshot("betterhud-settings")); } private static void assertScreenshotSaved(Path screenshot) { diff --git a/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java b/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java index 95c29ac..0781b76 100644 --- a/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java +++ b/src/gametest/java/dsns/betterhud/gametest/BetterHudClientGameTest.java @@ -5,9 +5,13 @@ import java.nio.file.Files; import java.nio.file.Path; +import com.terraformersmc.modmenu.ModMenu; +import com.terraformersmc.modmenu.gui.ModsScreen; + import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; import net.fabricmc.fabric.api.client.gametest.v1.context.TestSingleplayerContext; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; @SuppressWarnings("UnstableApiUsage") @@ -25,6 +29,22 @@ public void runTest(ClientGameTestContext context) { context.waitTicks(40); assertScreenshotSaved(context.takeScreenshot("betterhud-survival-world")); } + + // Back on the title screen: open the BetterHUD settings the way a + // player would, through Mod Menu's mod list. + context.runOnClient(client -> client.setScreen(new ModsScreen(client.screen))); + context.waitTicks(10); + assertScreenshotSaved(context.takeScreenshot("betterhud-mods-menu")); + + context.runOnClient(client -> { + Screen settings = ModMenu.getConfigScreen("betterhud", client.screen); + if (settings == null) { + throw new AssertionError("Mod Menu has no config screen registered for betterhud"); + } + client.setScreen(settings); + }); + context.waitTicks(10); + assertScreenshotSaved(context.takeScreenshot("betterhud-settings")); } private static void assertScreenshotSaved(Path screenshot) { diff --git a/src/gametest/resources/fabric.mod.json b/src/gametest/resources/fabric.mod.json index e432019..b5c39d0 100644 --- a/src/gametest/resources/fabric.mod.json +++ b/src/gametest/resources/fabric.mod.json @@ -11,6 +11,7 @@ }, "depends": { "betterhud": "*", - "fabric-client-gametest-api-v1": "*" + "fabric-client-gametest-api-v1": "*", + "modmenu": "*" } }