When the engine's present hook (the {@code eglSwapBuffers} detour) is live the tick is the
+ * true frame boundary and the derived rate is the game's exact frame rate. Without it the tick is
+ * inferred from render-entry timestamps, which can collapse or split frames, so the derived rate
+ * is close but not exact. Fail-closed: a source that cannot tell reports {@code false}, so the
+ * rate is labelled approximate rather than presented as exact.
+ */
+ default boolean isExact() {
+ return false;
+ }
}
diff --git a/app/src/main/java/org/chimeramc/client/core/backend/GameBackend.java b/app/src/main/java/org/chimeramc/client/core/backend/GameBackend.java
index 5851799f..be8b1cbd 100644
--- a/app/src/main/java/org/chimeramc/client/core/backend/GameBackend.java
+++ b/app/src/main/java/org/chimeramc/client/core/backend/GameBackend.java
@@ -81,6 +81,17 @@ enum Feature {
*/
int readFps();
+ /**
+ * Whether {@link #readFps()} is the game's exact frame rate rather than an inferred one.
+ *
+ * Only the present-boundary tick is exact; a rate derived from an inferred frame boundary is
+ * close but not exact, and a caller must label it approximate rather than pass it off as the
+ * game's real frame rate. Optional: a backend that cannot tell reports {@code false} (fail-closed).
+ */
+ default boolean isFrameRateExact() {
+ return false;
+ }
+
/** Releases every key and button this backend has injected, so input cannot stay stuck. */
void releaseAll();
diff --git a/app/src/main/java/org/chimeramc/client/core/backend/GameBridge.java b/app/src/main/java/org/chimeramc/client/core/backend/GameBridge.java
index 033d6d87..3510ec80 100644
--- a/app/src/main/java/org/chimeramc/client/core/backend/GameBridge.java
+++ b/app/src/main/java/org/chimeramc/client/core/backend/GameBridge.java
@@ -209,6 +209,25 @@ public static int readFps() {
}
}
+ /**
+ * Whether the last {@link #readFps()} reading is the game's exact frame rate.
+ *
+ *
Exact only when the engine's present hook is live and counting. An inferred tick (the hook
+ * absent) or no backend at all reads as approximate, so the FPS overlay marks the number instead
+ * of passing a close-but-not-exact rate off as the game's real frame rate. Fail-closed.
+ */
+ public static boolean isFrameRateExact() {
+ GameBackend b = backend;
+ if (b == null || !supports(GameBackend.Feature.FPS)) {
+ return false;
+ }
+ try {
+ return b.isFrameRateExact();
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
/** Releases every key and button the backend injected. Safe to call with no backend. */
public static void releaseAll() {
GameBackend b = backend;
diff --git a/app/src/main/java/org/chimeramc/client/core/backend/GameFrameRate.java b/app/src/main/java/org/chimeramc/client/core/backend/GameFrameRate.java
index 9b228a27..77327032 100644
--- a/app/src/main/java/org/chimeramc/client/core/backend/GameFrameRate.java
+++ b/app/src/main/java/org/chimeramc/client/core/backend/GameFrameRate.java
@@ -1,13 +1,19 @@
package org.chimeramc.client.core.backend;
/**
- * Derives the game's real frame rate from the sweetloader's render-hook frame counter.
+ * Derives the game's frame rate from the sweetloader's render-hook frame counter.
*
*
The game strips its symbols, so there is no frame-rate API to call. The sweetloader does,
* however, own a hook on the game's own player renderer ({@code GamePlayerRender}), and that hook
- * increments a monotonic tick once per frame it draws. Counting how many of those ticks elapse in a
- * wall-clock window is the game's own frame rate — a reading of frames the game actually presented,
- * not a guess and not the host's vsync callback.
+ * advances a monotonic tick once per frame. Counting how many of those ticks elapse in a wall-clock
+ * window is a reading of the game's own render path — not a guess and not the host's vsync callback.
+ *
+ *
How exact the reading is. The tick is the true frame boundary only while the engine's
+ * present hook (the {@code eglSwapBuffers} detour) is live and counting; then it is exact. Without
+ * that hook the tick is inferred from render-entry timestamps (a few-millisecond floor), which can
+ * collapse or split frames when the visible-model count changes, so the derived rate is close but not
+ * exact. A caller must read {@code PreloaderInput.isRenderTickExact()} and label the rate
+ * accordingly rather than presenting an inferred rate as the game's exact frame rate.
*
*
The counter is read on demand (the FPS overlay polls it), so this class is fed a
* {@code (renderTick, nowMs)} sample on each call and keeps only the first sample of the current
diff --git a/app/src/main/java/org/chimeramc/client/core/backend/HookAvailability.java b/app/src/main/java/org/chimeramc/client/core/backend/HookAvailability.java
index 072a7059..758089d4 100644
--- a/app/src/main/java/org/chimeramc/client/core/backend/HookAvailability.java
+++ b/app/src/main/java/org/chimeramc/client/core/backend/HookAvailability.java
@@ -34,7 +34,13 @@ public enum Hook {
/** The local-player feed, which the nametag icon and distance falloff need. */
LOCAL_PLAYER("local_player"),
/** The texture-cache flush callback, which a live cosmetic swap needs. */
- TEXTURE_FLUSH("texture_flush");
+ TEXTURE_FLUSH("texture_flush"),
+ /**
+ * The engine's present / frame-boundary hook (the {@code eglSwapBuffers} detour). Frame
+ * generation and motion blur both act on the presented frame, so neither can run until this
+ * hook is live. Absent on Vulkan-only builds that expose no libEGL present call.
+ */
+ PRESENT("present");
// Only hooks with a real "resolved" probe are listed. The pause-menu and HUD-screen queries
// report whether a screen is *open*, not whether the hook installed, so there is nothing
// honest to put here for them and they are deliberately absent rather than always-false.
diff --git a/app/src/main/java/org/chimeramc/client/core/backend/LauncherGameBackend.java b/app/src/main/java/org/chimeramc/client/core/backend/LauncherGameBackend.java
index 9d0dc8be..2555594d 100644
--- a/app/src/main/java/org/chimeramc/client/core/backend/LauncherGameBackend.java
+++ b/app/src/main/java/org/chimeramc/client/core/backend/LauncherGameBackend.java
@@ -167,6 +167,18 @@ public int readFps() {
return frameRate.sample(tick, clock.getAsLong());
}
+ /**
+ * Whether {@link #readFps()} is the game's exact frame rate.
+ *
+ *
Exact only when the tick source reports its tick is the engine's present boundary. An
+ * inferred tick reads as approximate so the overlay can mark the number, rather than passing a
+ * close-but-not-exact rate off as the game's real frame rate. Fail-closed when there is no source.
+ */
+ @Override
+ public boolean isFrameRateExact() {
+ return frameTickSource != null && frameTickSource.isExact();
+ }
+
/**
* Replaces the clock the frame-rate window is measured against.
*
diff --git a/app/src/main/java/org/chimeramc/client/core/backend/SweetLoaderFrameTickSource.java b/app/src/main/java/org/chimeramc/client/core/backend/SweetLoaderFrameTickSource.java
index a77c8afb..c95efdca 100644
--- a/app/src/main/java/org/chimeramc/client/core/backend/SweetLoaderFrameTickSource.java
+++ b/app/src/main/java/org/chimeramc/client/core/backend/SweetLoaderFrameTickSource.java
@@ -30,4 +30,9 @@ public long renderTick() {
return -1L;
}
}
+
+ @Override
+ public boolean isExact() {
+ return PreloaderInput.isRenderTickExact();
+ }
}
diff --git a/app/src/main/java/org/chimeramc/client/core/cosmetics/CosmeticsDiagnosticsReport.java b/app/src/main/java/org/chimeramc/client/core/cosmetics/CosmeticsDiagnosticsReport.java
index 96c7c8da..5cbd1b42 100644
--- a/app/src/main/java/org/chimeramc/client/core/cosmetics/CosmeticsDiagnosticsReport.java
+++ b/app/src/main/java/org/chimeramc/client/core/cosmetics/CosmeticsDiagnosticsReport.java
@@ -159,8 +159,6 @@ public static final class Inputs {
public boolean sessionActive;
/** The HUD hook's "a HUD screen is open" reading, or false when the hook has not fired. */
public boolean hudScreenOpen;
- /** Mod-menu build-step failures captured by the containment guard (formatted). */
- public String menuGuardFailures = " none\n";
public long timestampMs;
}
@@ -323,7 +321,6 @@ public static Inputs readInputs(Context context) {
AppLog.warnOnce("CaramelClient", "CosmeticsDiagnosticsReport:317", "Ignored a failure at CosmeticsDiagnosticsReport:317", ignored);
}
// Mod-menu build failures the containment guard caught.
- in.menuGuardFailures = org.chimeramc.client.core.mods.inbuilt.overlay.ModMenuGuard.format();
// Per-cosmetic asset audit: the real PNG size, the geometry's declared size and the cube
// count, so a size mismatch (which used to crash the preview) is visible without a device.
@@ -455,10 +452,6 @@ public static String format(Inputs in) {
b.append("Status: ").append(statusText(v)).append('\n');
b.append('\n');
- b.append("Mod-menu build failures (containment guard):\n");
- b.append(v.menuGuardFailures == null ? " none\n" : v.menuGuardFailures);
- b.append('\n');
-
b.append("Last crash report: ");
if (v.lastCrashReport == null || v.lastCrashReport.isEmpty()) {
b.append("none\n");
diff --git a/app/src/main/java/org/chimeramc/client/core/minecraft/FpsOptimizationService.java b/app/src/main/java/org/chimeramc/client/core/minecraft/FpsOptimizationService.java
index f4543bdd..ed9669de 100644
--- a/app/src/main/java/org/chimeramc/client/core/minecraft/FpsOptimizationService.java
+++ b/app/src/main/java/org/chimeramc/client/core/minecraft/FpsOptimizationService.java
@@ -3,7 +3,6 @@
import android.content.Context;
import android.os.PowerManager;
-import org.chimeramc.client.core.mods.inbuilt.overlay.RadialWheelMaterial;
import org.chimeramc.client.settings.FeatureSettings;
import org.chimeramc.client.settings.LowLatencyNetworkManager;
import org.chimeramc.client.settings.ThermalGovernor;
@@ -65,25 +64,6 @@ public static FpsOptimizer.Action apply() {
return action;
}
- /**
- * The radial wheel's blur budget for the current device state (R8).
- *
- *
This is the wheel's half of the same thermal ladder: it reuses {@link FpsOptimizer}'s
- * pressure decision rather than reading the sensors again, so the wheel cannot keep paying for a
- * per-open blur capture while the coordinator has already decided the host should step back. The
- * wheel calls this instead of judging the device itself.
- */
- public static RadialWheelMaterial.BlurBudget wheelBlurBudget() {
- FpsOptimizer.Action action = currentAction();
- if (action == FpsOptimizer.Action.SHED_BACKGROUND) {
- return RadialWheelMaterial.BlurBudget.NONE;
- }
- if (ThermalGovernor.severity() >= 1 && LowLatencyNetworkManager.isGameSessionActive()) {
- return RadialWheelMaterial.BlurBudget.REDUCED;
- }
- return RadialWheelMaterial.BlurBudget.FULL;
- }
-
private static boolean isBatterySaverOn() {
Context context = sAppContext;
if (context == null) return false;
diff --git a/app/src/main/java/org/chimeramc/client/core/mods/inbuilt/catalog/DebugScreenText.java b/app/src/main/java/org/chimeramc/client/core/mods/inbuilt/catalog/DebugScreenText.java
index d429e78c..84f23bee 100644
--- a/app/src/main/java/org/chimeramc/client/core/mods/inbuilt/catalog/DebugScreenText.java
+++ b/app/src/main/java/org/chimeramc/client/core/mods/inbuilt/catalog/DebugScreenText.java
@@ -33,6 +33,19 @@ public static final class Snapshot {
public String abi = "unknown";
/** Frames per second over the last second, or 0 before two frames are known. */
public int fps;
+ /**
+ * True when {@link #fps} is the launcher's own refresh cadence rather than the game's
+ * render-tick rate. It is the launcher process's {@code Choreographer} tick, which is not
+ * the game's frame rate and not the game's present rate either, so it is shown with a
+ * {@code ~} and named, never passed off as the game's frame rate.
+ */
+ public boolean fpsIsLauncherRefresh;
+ /**
+ * True when {@link #fps} is the game's own render-tick rate but the tick was inferred from
+ * render-entry timestamps rather than the exact present boundary (the present hook is absent
+ * on this build). The rate is close but not exact, so it is marked and named.
+ */
+ public boolean fpsIsApproximate;
/** The 1% low frame rate (the worst frame in the window), or 0 when unknown. */
public int onePercentLowFps;
/** The player's world position, or null when the feed has no reading. */
@@ -94,9 +107,19 @@ public static String build(Snapshot s) {
return b.toString();
}
- /** "FPS: 60 (1% low 41)". The low is omitted when the window has no reading for it. */
+ /**
+ * "FPS: 60 (1% low 41)". The low is omitted when the window has no reading for it.
+ *
+ *
When the reading is the launcher's own refresh cadence (the game's own render-tick counter
+ * is unavailable), the line is marked with {@code ~} and named as the launcher refresh rate, so a
+ * debug screenshot never shows the launcher's own vsync cadence as if it were the game's frame
+ * rate. When the game's own tick was inferred rather than the exact present boundary, the rate is
+ * marked and named as approximate for the same reason.
+ */
private static String frameRateLine(Snapshot v) {
if (v.fps <= 0) return "FPS: unavailable";
+ if (v.fpsIsLauncherRefresh) return "FPS: ~" + v.fps + " (launcher refresh, not the game's)";
+ if (v.fpsIsApproximate) return "FPS: ~" + v.fps + " (approximate; present hook absent)";
if (v.onePercentLowFps > 0 && v.onePercentLowFps != v.fps) {
return "FPS: " + v.fps + " (1% low " + v.onePercentLowFps + ")";
}
diff --git a/app/src/main/java/org/chimeramc/client/core/mods/inbuilt/overlay/FpsReadout.java b/app/src/main/java/org/chimeramc/client/core/mods/inbuilt/overlay/FpsReadout.java
new file mode 100644
index 00000000..a4c59180
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/core/mods/inbuilt/overlay/FpsReadout.java
@@ -0,0 +1,133 @@
+package org.chimeramc.client.core.mods.inbuilt.overlay;
+
+/**
+ * Picks which frame-rate reading to show, and names where it came from.
+ *
+ *
Three readings can back the FPS readout, and they are not the same number:
+ *
+ *
+ * The exact game rate , derived from the sweetloader's render-hook frame counter while
+ * the engine's present hook is live — the tick is the true frame boundary, so this is the
+ * game's real frame rate.
+ * The approximate game rate , derived from the same counter while the present hook is
+ * absent. The tick is then inferred from render-entry timestamps, which can collapse or split
+ * frames when the visible-model count changes, so the rate is close but not exact.
+ * The launcher refresh cadence , counted from the launcher process's own
+ * {@code Choreographer} callbacks. This is not the game's frame rate and not the
+ * game's present rate: the launcher does not own the game's {@code eglSwapBuffers}, so this
+ * counts the launcher main thread's vsync ticks (the display refresh cadence) and keeps
+ * counting while the game is paused or loading. It is a liveness reading of last resort.
+ *
+ *
+ * The exact game rate wins whenever it is available; the approximate game rate is preferred over
+ * the host cadence (it is at least the game's own render path) and both are labelled. Keeping the
+ * choice here — rather than inline in the overlay — means the rule is a pure function with a unit
+ * test, so a surface can never quietly pass one measurement off as another.
+ */
+public final class FpsReadout {
+
+ /** Where a reading came from, so a surface can name it instead of showing a bare number. */
+ public enum Source {
+ /** No reading from any source. */
+ NONE,
+ /** The game's own rate, from the exact present boundary. */
+ GAME,
+ /** The game's own rate, from an inferred frame boundary — close, but not exact. */
+ GAME_APPROXIMATE,
+ /**
+ * The launcher's own refresh cadence, from this process's {@code Choreographer} callbacks.
+ * Not the game's rate at all, and not the game's present rate either: it is the launcher
+ * main thread's vsync tick.
+ */
+ HOST_REFRESH
+ }
+
+ /** The value to display, or 0 when there is no reading from any source. */
+ public final int value;
+ /** Where {@link #value} came from. */
+ public final Source source;
+
+ private FpsReadout(int value, Source source) {
+ this.value = value;
+ this.source = source;
+ }
+
+ /** True when there is a number to show. */
+ public boolean hasReading() {
+ return value > 0 && source != Source.NONE;
+ }
+
+ /** True when the value is not the game's exact frame rate. */
+ public boolean approximate() {
+ return source == Source.GAME_APPROXIMATE || source == Source.HOST_REFRESH;
+ }
+
+ /** True when the value is the launcher's refresh cadence rather than the game's own rate. */
+ public boolean isHostRefresh() {
+ return source == Source.HOST_REFRESH;
+ }
+
+ /**
+ * The value to show next to an "FPS" unit, or 0 when there is none.
+ *
+ *
Only the game's own render rate may sit beside the unit: a headline of "60 FPS" that is
+ * actually the launcher's vsync cadence is the fabricated-frame bug, and the "~" marker does not
+ * repair it. The launcher's cadence is exposed separately by {@link #launcherRefreshHz()} so a
+ * surface can name it as what it is.
+ */
+ public int gameValue() {
+ return (source == Source.GAME || source == Source.GAME_APPROXIMATE) ? value : 0;
+ }
+
+ /**
+ * The launcher's own refresh cadence, in Hz, or 0 when the headline is already the game's rate.
+ *
+ *
This is a liveness reading of the launcher main thread's vsync tick — it keeps counting
+ * while the game is paused or loading — so it is a different measurement from the game's frame
+ * rate and must never be rendered under an "FPS" unit.
+ */
+ public int launcherRefreshHz() {
+ return source == Source.HOST_REFRESH ? value : 0;
+ }
+
+ /**
+ * Chooses the reading, treating a positive game rate as exact.
+ *
+ *
Kept for callers that already know the tick is the exact present boundary (and for tests).
+ */
+ public static FpsReadout of(int gameFps, int hostFps) {
+ return of(gameFps, true, hostFps);
+ }
+
+ /**
+ * Chooses the reading.
+ *
+ * @param gameFps the game's own frame rate, or {@code <= 0} when the render hook has not produced
+ * one
+ * @param gameExact whether that game rate came from the exact present boundary; when false it is
+ * shown as approximate
+ * @param hostFps the launcher refresh cadence, or {@code <= 0} when no callback has fired yet
+ */
+ public static FpsReadout of(int gameFps, boolean gameExact, int hostFps) {
+ if (gameFps > 0) {
+ return new FpsReadout(gameFps, gameExact ? Source.GAME : Source.GAME_APPROXIMATE);
+ }
+ if (hostFps > 0) {
+ return new FpsReadout(hostFps, Source.HOST_REFRESH);
+ }
+ return new FpsReadout(0, Source.NONE);
+ }
+
+ /**
+ * The value as the readout text, marked with {@code ~} when it is not the game's exact rate.
+ *
+ *
The launcher's own refresh cadence has no game rate behind it, so it reads as the
+ * no-reading dash here rather than as a number under "FPS". The cadence itself is available from
+ * {@link #launcherRefreshHz()} for a surface that wants to name it.
+ */
+ public String text() {
+ int game = gameValue();
+ if (game <= 0) return ModStatsFormatter.NO_READING;
+ return ModStatsFormatter.fps(game, approximate());
+ }
+}
diff --git a/app/src/main/java/org/chimeramc/client/core/mods/render/PresentFrameCatalog.java b/app/src/main/java/org/chimeramc/client/core/mods/render/PresentFrameCatalog.java
new file mode 100644
index 00000000..fc9fa372
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/core/mods/render/PresentFrameCatalog.java
@@ -0,0 +1,167 @@
+package org.chimeramc.client.core.mods.render;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * The catalogue of the two present-frame effect modules: their ids and every configurable option,
+ * with the default value and the bounds.
+ *
+ *
Honesty contract (read this before adding an option). Both modules act on the frame the
+ * engine is about to present, on the sweetloader's present / frame-boundary hook (the
+ * {@code eglSwapBuffers} detour). That is the only place a second presented frame can be emitted or
+ * the previous frame blended in, and it is a hook that is not live on every build (a Vulkan-only
+ * build exposes no libEGL present call). So:
+ *
+ *
+ * Neither module claims to raise the game's frame rate. Frame generation blends the previous
+ * frame into the current one so the presented image sits between the two frames the game
+ * already drew — motion reads as smoother at the same render rate, but no extra frame is
+ * presented; motion blur blends the previous frame in, which softens the image by design.
+ * Both are gated on the live present hook, not on the static "needs game data" list: they are
+ * badged while the hook is absent and become usable the moment it installs.
+ *
+ *
+ * Pure data and pure helpers, so the counts, the key uniqueness and the bounds are unit-tested
+ * without a device — see {@code PresentFrameModulesTest}.
+ */
+public final class PresentFrameCatalog {
+
+ /** One configurable option of a present-frame module. */
+ public static final class Option {
+ public final String key;
+ public final String label;
+ /** "toggle" or "slider" (an int slider with min/max). */
+ public final String type;
+ public final String defaultValue;
+ public final int min;
+ public final int max;
+ public final String description;
+
+ Option(String key, String label, String type, String defaultValue, int min, int max,
+ String description) {
+ this.key = key;
+ this.label = label;
+ this.type = type;
+ this.defaultValue = defaultValue;
+ this.min = min;
+ this.max = max;
+ this.description = description;
+ }
+
+ public boolean isToggle() {
+ return "toggle".equals(type);
+ }
+ }
+
+ /** One present-frame module: its identity plus its options. */
+ public static final class Module {
+ public final String id;
+ public final String title;
+ public final String description;
+ public final List options;
+
+ Module(String id, String title, String description, List options) {
+ this.id = id;
+ this.title = title;
+ this.description = description;
+ this.options = Collections.unmodifiableList(options);
+ }
+ }
+
+ public static final String FRAME_GENERATION = "frame_generation";
+ public static final String MOTION_BLUR = "motion_blur";
+
+ private static final List MODULES;
+
+ static {
+ List modules = new ArrayList<>();
+
+ // --- Frame generation ---------------------------------------------------------------------
+ List frameGen = new ArrayList<>();
+ frameGen.add(toggle("fg_enabled", "Frame generation",
+ "Blends the previous frame into the current one on the present boundary, so the "
+ + "presented image sits between the two frames the game drew. Motion reads as "
+ + "smoother; the game's render rate is unchanged and no extra frame is "
+ + "presented."));
+ frameGen.add(slider("fg_strength", "Interpolation strength (%)", 50, 0, 100,
+ "How far the blend is pushed toward the previous frame. Higher reads as smoother but "
+ + "mixes more of the old frame in, so fast motion and edges show more artefacts."));
+ frameGen.add(slider("fg_max_multiplier", "Max presented frames per rendered frame", 2, 2, 3,
+ "Reserved. The seam presents one frame per rendered frame — it blends, it does not "
+ + "insert an extra present — so this ceiling is not acted on yet; it is kept "
+ + "for a future true frame-insertion seam."));
+ frameGen.add(toggle("fg_hold_artifacts", "Reduce edge artefacts",
+ "Bias the blend to reuse less of the previous frame near edges, which trades a "
+ + "little smoothness for fewer warping artefacts."));
+ modules.add(new Module(FRAME_GENERATION,
+ "Frame Generation",
+ "Blends the previous presented frame into the current one on the engine's present "
+ + "boundary, so motion reads as smoother at the same render rate. It does not "
+ + "make the game render faster and it does not present an extra frame.", frameGen));
+
+ // --- Motion blur --------------------------------------------------------------------------
+ List motionBlur = new ArrayList<>();
+ motionBlur.add(toggle("mb_enabled", "Motion blur",
+ "Blend the previous presented frame into the new one, weighted by how fast the view "
+ + "is moving. Fast movement smears along the direction of travel."));
+ motionBlur.add(slider("mb_strength", "Blur strength (%)", 35, 0, 100,
+ "How much of the previous frame is blended in. Higher is a heavier smear and a "
+ + "softer image."));
+ motionBlur.add(slider("mb_budget", "Blend budget (%)", 60, 10, 100,
+ "The most of the frame time the blend may take. A bounded budget keeps the effect "
+ + "from costing more than it returns on a weak device."));
+ motionBlur.add(toggle("mb_motion_weight", "Weight by view speed",
+ "Scale the blur by how fast the camera is turning, so a still view stays sharp and "
+ + "only motion smears."));
+ modules.add(new Module(MOTION_BLUR,
+ "Motion Blur",
+ "Blends the previous presented frame into the new one, weighted by how fast the view "
+ + "is moving. It softens the image by design and does not add frames.",
+ motionBlur));
+
+ MODULES = Collections.unmodifiableList(modules);
+ }
+
+ private PresentFrameCatalog() {
+ }
+
+ /** Every present-frame module, in display order. */
+ public static List modules() {
+ return MODULES;
+ }
+
+ /** The module with this id, or null. */
+ public static Module find(String id) {
+ for (Module m : MODULES) {
+ if (m.id.equals(id)) {
+ return m;
+ }
+ }
+ return null;
+ }
+
+ /** True when this id is one of the present-frame modules. */
+ public static boolean isPresentFrameModule(String id) {
+ return find(id) != null;
+ }
+
+ /** Every option across every module, for a key-uniqueness / count assertion. */
+ public static List allOptions() {
+ List out = new ArrayList<>();
+ for (Module m : MODULES) {
+ out.addAll(m.options);
+ }
+ return out;
+ }
+
+ private static Option toggle(String key, String label, String description) {
+ return new Option(key, label, "toggle", "false", 0, 0, description);
+ }
+
+ private static Option slider(String key, String label, int def, int min, int max,
+ String description) {
+ return new Option(key, label, "slider", String.valueOf(def), min, max, description);
+ }
+}
diff --git a/app/src/main/java/org/chimeramc/client/core/mods/render/PresentFramePolicy.java b/app/src/main/java/org/chimeramc/client/core/mods/render/PresentFramePolicy.java
new file mode 100644
index 00000000..55239616
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/core/mods/render/PresentFramePolicy.java
@@ -0,0 +1,117 @@
+package org.chimeramc.client.core.mods.render;
+
+/**
+ * The decisions the two present-frame effect modules actually make, as a pure function of their
+ * options and whether the present hook is live.
+ *
+ * Separated from the options so the effect of a switch is a testable function, not a hidden side
+ * effect at a call site. Nothing here touches Minecraft, GL or the sweetloader; the native present
+ * seam reads a {@link Plan} and does the draw. That keeps the honesty contract checkable off-device:
+ *
+ *
+ * the plan carries no frame-rate figure — neither module measures or promises one;
+ * when the present hook is not live the plan is {@link Plan#INACTIVE}, so a stored switch can
+ * never drive a seam that is not there;
+ * the blur's budget is clamped, because the effect cannot be free and a bounded budget is what
+ * stops it costing more than it returns.
+ *
+ */
+public final class PresentFramePolicy {
+
+ /** What the present seam should do with the frame this call, for both modules at once. */
+ public static final class Plan {
+ /** Whether frame generation is enabled and its hook is live. */
+ public final boolean frameGenerationEnabled;
+ /** 0..1 how far the blend is pushed toward the previous of the two real frames. */
+ public final float interpolationStrength;
+ /** 2 or 3: reserved ceiling on presented frames per rendered frame (not acted on yet). */
+ public final int maxFramesPerRendered;
+ /** Bias the blend to reuse less of the previous frame near edges. */
+ public final boolean reduceEdgeArtifacts;
+
+ /** Whether motion blur is enabled and its hook is live. */
+ public final boolean motionBlurEnabled;
+ /** 0..1: how much of the previous frame is blended in. */
+ public final float blurStrength;
+ /** 0..1: the most of the frame time the blend may take. */
+ public final float blurBudget;
+ /** Scale the blur by camera speed, so a still view stays sharp. */
+ public final boolean weightByViewSpeed;
+
+ /** The plan when neither effect can run (module off, or the present hook is absent). */
+ public static final Plan INACTIVE = new Plan(false, 0f, 2, false, false, 0f, 0f, false);
+
+ Plan(boolean frameGenerationEnabled, float interpolationStrength, int maxFramesPerRendered,
+ boolean reduceEdgeArtifacts, boolean motionBlurEnabled, float blurStrength,
+ float blurBudget, boolean weightByViewSpeed) {
+ this.frameGenerationEnabled = frameGenerationEnabled;
+ this.interpolationStrength = interpolationStrength;
+ this.maxFramesPerRendered = maxFramesPerRendered;
+ this.reduceEdgeArtifacts = reduceEdgeArtifacts;
+ this.motionBlurEnabled = motionBlurEnabled;
+ this.blurStrength = blurStrength;
+ this.blurBudget = blurBudget;
+ this.weightByViewSpeed = weightByViewSpeed;
+ }
+
+ /** Whether any effect in this plan will do work this frame. */
+ public boolean isActive() {
+ return frameGenerationEnabled || motionBlurEnabled;
+ }
+ }
+
+ /** The multiplier ceiling: more presented frames is more artefacts than benefit. */
+ public static final int MAX_MULTIPLIER = 3;
+
+ private PresentFramePolicy() {
+ }
+
+ /**
+ * Builds the plan.
+ *
+ * @param presentHookLive whether the engine's present/frame-boundary hook is installed; when it
+ * is not, both effects are off regardless of their switches
+ * @param frameGenEnabled the frame-generation module switch
+ * @param motionBlurEnabled the motion-blur module switch
+ * @param values the option values, keyed by option key; a missing key reads its default
+ */
+ public static Plan plan(boolean presentHookLive, boolean frameGenEnabled,
+ boolean motionBlurEnabled, ValueSource values) {
+ if (!presentHookLive) {
+ return Plan.INACTIVE;
+ }
+
+ boolean frameGen = frameGenEnabled && values.bool("fg_enabled", false);
+ float strength = frameGen ? clamp01(values.integer("fg_strength", 50) / 100f) : 0f;
+ int multiplier = clampMultiplier(values.integer("fg_max_multiplier", 2));
+ boolean reduceArtifacts = frameGen && values.bool("fg_hold_artifacts", false);
+
+ boolean blur = motionBlurEnabled && values.bool("mb_enabled", false);
+ float blurStrength = blur ? clamp01(values.integer("mb_strength", 35) / 100f) : 0f;
+ float blurBudget = blur ? clamp(values.integer("mb_budget", 60) / 100f, 0.10f, 1.0f) : 0f;
+ boolean weightBySpeed = blur && values.bool("mb_motion_weight", false);
+
+ return new Plan(frameGen, strength, multiplier, reduceArtifacts,
+ blur, blurStrength, blurBudget, weightBySpeed);
+ }
+
+ private static int clampMultiplier(int value) {
+ if (value < 2) return 2;
+ return Math.min(value, MAX_MULTIPLIER);
+ }
+
+ private static float clamp01(float value) {
+ return clamp(value, 0f, 1f);
+ }
+
+ private static float clamp(float value, float min, float max) {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ /** Reads option values by key, so the policy is testable without a SharedPreferences. */
+ public interface ValueSource {
+ int integer(String key, int fallback);
+
+ boolean bool(String key, boolean fallback);
+ }
+}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/Application.kt b/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/Application.kt
index 1e3a9388..c9edcea2 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/Application.kt
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/Application.kt
@@ -41,6 +41,8 @@ class LauncherApplication : Application() {
org.chimeramc.client.preloader.PreloaderInput.isLocalPlayerAvailable()
org.chimeramc.client.core.backend.HookAvailability.Hook.TEXTURE_FLUSH ->
org.chimeramc.client.preloader.PreloaderInput.isTextureCacheFlushAvailable()
+ org.chimeramc.client.core.backend.HookAvailability.Hook.PRESENT ->
+ org.chimeramc.client.preloader.PreloaderInput.isPresentHookLive()
}
}
// Send any crash report captured by a previous run, but only when the user's own
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/MinecraftActivity.kt b/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/MinecraftActivity.kt
index d7ece1c2..f99aad86 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/MinecraftActivity.kt
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/minecraft/MinecraftActivity.kt
@@ -339,11 +339,15 @@ class MinecraftActivity : MainActivity(), PojavControlsHost {
private fun startInbuiltModServices() {
overlayManager = InbuiltOverlayManager(this)
overlayManager?.showEnabledOverlays()
+ // The in-game Mod Menu is drawn natively on the game's GL context; the controller builds its
+ // catalogue and forwards input into it for as long as the session lives.
+ org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController.install(this)
}
private fun stopInbuiltModServices() {
overlayManager?.hideAllOverlays()
overlayManager = null
+ org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController.release(this)
}
override fun onNewIntent(intent: Intent) {
@@ -506,18 +510,9 @@ class MinecraftActivity : MainActivity(), PojavControlsHost {
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
- // A bind picker in the Mod Menu must see the raw press before the sweetloader can consume
- // it, otherwise the button the player presses never reaches the picker.
- if (event.action == KeyEvent.ACTION_DOWN) {
- if (org.chimeramc.client.core.mods.inbuilt.overlay.ModMenuOverlay
- .deliverBindKey(event.keyCode)) {
- return true
- }
- }
- // An open Mod Menu takes controller navigation next. Its overlay root is unfocusable so the
- // platform stops painting the white focus wash over the game, which also means the
- // framework will not route a d-pad or A press to it — so it is offered the raw key here,
- // before the sweetloader can swallow the press for the game.
+ // An open Mod Menu takes controller navigation next. It is drawn natively (there is no
+ // Android view to focus), so it is offered the raw key here, before the sweetloader can
+ // swallow the press for the game.
if (overlayManager?.handleMenuControllerKey(event.keyCode, event.action) == true) {
return true
}
@@ -587,6 +582,13 @@ class MinecraftActivity : MainActivity(), PojavControlsHost {
}
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
+ // An open Mod Menu owns every touch: it is a full-screen overlay drawn into the game's
+ // back buffer, so the game must not also receive the tap.
+ if (org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController.getInstance()
+ ?.handleTouch(event) == true) {
+ return true
+ }
+
if (shouldConsumeMouseMotion(event)) {
return true
}
@@ -873,6 +875,10 @@ class MinecraftActivity : MainActivity(), PojavControlsHost {
flushLookDelta()
super.tick()
overlayManager?.tick()
+ // Apply whatever the native menu raised this frame (a toggle, a config change, a favourite)
+ // on the main thread, where the launcher's own module changes also happen.
+ org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController.getInstance()
+ ?.drainAndApply()
}
override fun getDataDir(): File {
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/InbuiltModuleProvider.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/InbuiltModuleProvider.java
index 7154ea42..15960241 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/InbuiltModuleProvider.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/InbuiltModuleProvider.java
@@ -3,12 +3,8 @@
import org.chimeramc.client.util.AppLog;
import android.app.Activity;
-import android.content.Context;
import android.graphics.Color;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
import org.chimeramc.client.R;
import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
@@ -26,84 +22,6 @@ public final class InbuiltModuleProvider {
private static final String GROUP_PVP_ID = ModIds.GROUP_PVP;
private static final String MOD_ID = "inbuilt";
- private static final String CFG_OVERLAY_SIZE = "overlay_size";
- private static final String CFG_OVERLAY_OPACITY = "overlay_opacity";
- private static final String CFG_OVERLAY_LOCK = "overlay_lock";
- private static final String CFG_OVERLAY_SHOW_EVERYWHERE = "overlay_show_everywhere";
- private static final String CFG_FRAME_GRAPH = "overlay_frame_graph";
- private static final String CFG_FPS_STATS = "overlay_fps_stats";
- private static final String CFG_FPS_STATS_WINDOW = "overlay_fps_stats_window";
- private static final String CFG_AUTO_SPRINT_KEYBIND = "auto_sprint_keybind";
- private static final String CFG_CURSOR_SENSITIVITY = "cursor_sensitivity";
- private static final String CFG_ZOOM_LEVEL = "zoom_level";
- private static final String CFG_ZOOM_TRANSITION = "zoom_transition";
- private static final String CFG_ZOOM_KEYBIND = "zoom_keybind";
- private static final String CFG_ZOOM_SCROLL_SENSITIVITY = "zoom_scroll_sensitivity";
- private static final String CFG_ZOOM_SMOOTHING = "zoom_smoothing";
- private static final String CFG_SNAPLOOK_KEYBIND = "snaplook_keybind";
- private static final String CFG_SNAPLOOK_RETURN_SPEED = "snaplook_return_speed";
- private static final String CFG_GYRO_SENSITIVITY_X = "gyro_sensitivity_x";
- private static final String CFG_GYRO_SENSITIVITY_Y = "gyro_sensitivity_y";
- private static final String CFG_GYRO_INVERT_X = "gyro_invert_x";
- private static final String CFG_GYRO_INVERT_Y = "gyro_invert_y";
- private static final String CFG_GYRO_DEADZONE = "gyro_deadzone";
- private static final String CFG_HOTBAR_ITEM_ICONS = "hotbar_item_icons";
- private static final String CFG_OFFHAND_MODE = "offhand_mode";
- private static final String CFG_OFFHAND_INVENTORY_KEY = "offhand_inventory_key";
- private static final String CFG_OFFHAND_SWAP_KEY = "offhand_swap_key";
- private static final String CFG_OFFHAND_USE_KEY = "offhand_use_key";
- private static final String CFG_OFFHAND_KEYBIND = "offhand_keybind";
- private static final String CFG_HOTBAR_SLOT_PREFIX = "hotbar_slot_";
- private static final String CFG_HOTBAR_SLOT_ENABLED = "enabled";
- private static final String CFG_HOTBAR_SLOT_SIZE = "size";
- private static final String CFG_HOTBAR_SLOT_OPACITY = "opacity";
- private static final String CFG_AIM_SMOOTHING = "aim_smoothing";
- private static final String CFG_AIM_CROSSHAIR = "aim_crosshair";
- private static final String CFG_AIM_FLASH = "aim_flash";
- private static final String CFG_AIM_SENSITIVITY = "aim_sensitivity";
- private static final String CFG_AIM_CROSSHAIR_STYLE = "aim_crosshair_style";
- private static final String CFG_AIM_CROSSHAIR_COLOR = "aim_crosshair_color";
- private static final String CFG_ARMOR_HUD_SHOW_TARGET = "armor_hud_show_target";
- private static final String CFG_ARMOR_HUD_SHOW_ENCHANTS = "armor_hud_show_enchants";
- private static final String CFG_ARMOR_HUD_STACKED = "armor_hud_stacked";
- private static final String CFG_ARMOR_HUD_REFRESH_MS = "armor_hud_refresh_ms";
- private static final String CFG_CRYSTAL_MIN_SELF_HP = "crystal_min_self_hp";
- private static final String CFG_CRYSTAL_MAX_RANGE = "crystal_max_range";
- private static final String CFG_CRYSTAL_PLACEMENT_DELAY_MS = "crystal_placement_delay_ms";
- private static final String CFG_CRYSTAL_MANUAL_ASSIST = "crystal_manual_assist";
- private static final String CFG_CRYSTAL_KEYBIND = "crystal_keybind";
- private static final String CFG_HITREG_SENSITIVITY = "hitreg_sensitivity";
- private static final String CFG_HITREG_SMOOTHING = "hitreg_smoothing";
- private static final String CFG_HITREG_PREDICTION = "hitreg_prediction";
- private static final String CFG_HITREG_HAPTIC = "hitreg_haptic";
- private static final String CFG_HIT_TIMING_COOLDOWN_MS = "hit_timing_cooldown_ms";
- private static final String CFG_HIT_TIMING_SHOW_COMBO = "hit_timing_show_combo";
- private static final String CFG_HIT_TIMING_SHOW_BAR = "hit_timing_show_bar";
- private static final String CFG_HITBOX_SHOW_PLAYERS = "hitbox_show_players";
- private static final String CFG_HITBOX_SHOW_MOBS = "hitbox_show_mobs";
- private static final String CFG_HITBOX_SHOW_ITEMS = "hitbox_show_items";
- private static final String CFG_HITBOX_SHOW_PROJECTILES = "hitbox_show_projectiles";
- private static final String CFG_HITBOX_SHOW_THROWN_ITEMS = "hitbox_show_thrown_items";
- private static final String CFG_HITBOX_SHOW_LOOK_LINE = "hitbox_show_look_line";
- private static final String CFG_HITBOX_SHOW_CRIT_LINE = "hitbox_show_crit_line";
- private static final String CFG_HITBOX_SHOW_COMBO_BOX = "hitbox_show_combo_box";
- private static final String CFG_HITBOX_SHOW_TARGET_BOX = "hitbox_show_target_box";
- private static final String CFG_VOICE_RANGE = "voice_range_blocks";
- private static final String CFG_VOICE_VOLUME = "voice_volume_percent";
- private static final String CFG_VOICE_CHANNEL = "voice_channel";
- private static final String CFG_VOICE_MIC = "voice_mic_enabled";
- private static final String CFG_VOICE_ICON_STYLE = "voice_icon_style";
- private static final String CFG_VOICE_ICON_ANIMATE = "voice_icon_animate";
- private static final String CFG_VOICE_ICON_NAMETAG = "voice_icon_show_nametag";
- // PvP Suite (V1.1): overlay-only visual modules, filed under the General tab.
- private static final String CFG_REACH_POSITION = "reach_indicator_position";
- private static final String CFG_TRAJECTORY_COLOR = "trajectory_color";
- private static final String CFG_HIT_PREDICTION_LOOKAHEAD = "hit_prediction_lookahead_ms";
- private static final String CFG_KILL_EFFECT_STYLE = "kill_effect_style";
- private static final String CFG_KILL_EFFECT_COLOR = "kill_effect_color";
- private static final String CFG_CATALOG_CHAT_MACRO = "catalog_chat_macro";
- private static final String CFG_CATALOG_COMMAND_BIND = "catalog_command_bind";
-
private InbuiltModuleProvider() {
}
@@ -226,6 +144,9 @@ public static List load(Activity activity) {
R.string.inbuilt_mod_block_outline, R.string.inbuilt_mod_block_outline_desc, groupName));
mods.add(create(activity, manager, overlayManager, ModIds.MOTION_BLUR,
R.string.inbuilt_mod_motion_blur, R.string.inbuilt_mod_motion_blur_desc, groupName));
+ mods.add(create(activity, manager, overlayManager, ModIds.FRAME_GENERATION,
+ R.string.inbuilt_mod_frame_generation,
+ R.string.inbuilt_mod_frame_generation_desc, groupName));
mods.add(create(activity, manager, overlayManager, ModIds.SATURATION,
R.string.inbuilt_mod_saturation, R.string.inbuilt_mod_saturation_desc, groupName));
mods.add(create(activity, manager, overlayManager, ModIds.CROSSHAIR,
@@ -282,108 +203,18 @@ private static void addPvpCatalogEntries(Activity activity, InbuiltModManager ma
overlayManager != null
? overlayManager.isModActive(entry.id)
: manager.resolveInbuiltModEnabled(entry.id, false),
- createCatalogConfigs(activity, manager, entry.id),
+ ModConfigSchema.createCatalogConfigs(activity, manager, entry.id),
false,
GROUP_PVP_ID,
pvpGroupName,
(m, enabled) -> setEnabled(manager, m, enabled),
(m, config, value) -> setConfig(manager, m, config, value)
);
- mod.setLocalConfigSchema(createCatalogConfigSchema(activity, mod));
+ mod.setLocalConfigSchema(ModConfigSchema.createCatalogConfigSchema(activity, mod));
mods.add(mod);
}
}
- /** A catalogue module's own options: macro text, or the readout's overlay appearance. */
- private static List createCatalogConfigs(Context context,
- InbuiltModManager manager,
- String modId) {
- List configs = new ArrayList<>();
- if ("util_chat_macro".equals(modId)) {
- configs.add(config(CFG_CATALOG_CHAT_MACRO,
- context.getString(R.string.catalog_macro_text),
- UnifiedMod.ConfigType.TEXT, "", "", "",
- manager.getCatalogChatMacro()));
- } else if ("util_command_bind".equals(modId)) {
- configs.add(config(CFG_CATALOG_COMMAND_BIND,
- context.getString(R.string.catalog_command_text),
- UnifiedMod.ConfigType.TEXT, "", "", "",
- manager.getCatalogCommandBind()));
- }
- if (org.chimeramc.client.core.mods.inbuilt.overlay.CatalogModuleHost
- .isReadout(modId)) {
- configs.add(config(CFG_OVERLAY_SIZE,
- context.getString(R.string.mod_config_overlay_button_size_dp),
- UnifiedMod.ConfigType.SLIDER_INT, "56", "20", "100",
- String.valueOf(manager.getOverlayButtonSize(modId))));
- configs.add(config(CFG_OVERLAY_OPACITY,
- context.getString(R.string.mod_config_overlay_opacity_percent),
- UnifiedMod.ConfigType.SLIDER_INT, "100", "0", "100",
- String.valueOf(manager.getOverlayOpacity(modId))));
- configs.add(config(CFG_OVERLAY_LOCK,
- context.getString(R.string.overlay_button_lock),
- UnifiedMod.ConfigType.TOGGLE, "false", "", "",
- String.valueOf(manager.isOverlayLocked(modId))));
- configs.add(config(CFG_OVERLAY_SHOW_EVERYWHERE,
- context.getString(R.string.mod_config_overlay_show_everywhere),
- UnifiedMod.ConfigType.TOGGLE, "false", "", "",
- String.valueOf(manager.isOverlayShowEverywhere(modId))));
- }
- return configs;
- }
-
- /**
- * A catalogue module's config dialog: its own content/display options plus a scope note that
- * says which route the module uses, so a working module does not read as a roadmap stub.
- */
- private static RuntimeConfigSchema createCatalogConfigSchema(Context context, UnifiedMod mod) {
- String modId = mod.getId();
- try {
- JSONArray categories = new JSONArray();
- JSONArray nodes = new JSONArray();
- boolean readout = org.chimeramc.client.core.mods.inbuilt.overlay
- .CatalogModuleHost.isReadout(modId);
- categories.put(configCategory(context, "content", R.string.mod_config_category_behavior));
- if (readout) {
- categories.put(configCategory(context, "display", R.string.mod_config_category_appearance));
- }
- if ("util_chat_macro".equals(modId)) {
- nodes.put(configNode(context, mod, CFG_CATALOG_CHAT_MACRO, "content"));
- } else if ("util_command_bind".equals(modId)) {
- nodes.put(configNode(context, mod, CFG_CATALOG_COMMAND_BIND, "content"));
- }
- if (readout) {
- nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "display"));
- }
- int noteRes = catalogScopeNoteRes(modId);
- if (noteRes != 0) {
- nodes.put(scopeNoteNode(context, mod, noteRes, "content"));
- }
- return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
- .put("default_category", "content")
- .put("categories", categories).put("nodes", nodes).toString());
- } catch (JSONException e) {
- throw new IllegalStateException("Unable to build catalogue config schema", e);
- }
- }
-
- private static int catalogScopeNoteRes(String modId) {
- switch (org.chimeramc.client.core.mods.inbuilt.catalog.PvpModuleScopes.scopeOf(modId)) {
- case GAME_OPTIONS:
- return R.string.catalog_options_scope_note;
- case LOCAL_FEED:
- case PEER_FEED:
- return R.string.catalog_local_scope_note;
- case DEVICE_INPUT:
- return R.string.catalog_scope_note;
- default:
- return 0;
- }
- }
-
/**
* Orders the menu: one contiguous run per group id, each sorted alphabetically by display name,
* with the PvP group last.
@@ -472,7 +303,7 @@ private static UnifiedMod create(Activity activity, InbuiltModManager manager,
MOD_ID,
UnifiedMod.Source.INBUILT,
active,
- createConfigs(activity, manager, id),
+ ModConfigSchema.createConfigs(activity, manager, id),
customConfig,
pvpModule ? GROUP_PVP_ID : GROUP_ID,
pvpModule ? activity.getString(R.string.mod_menu_group_pvp) : groupName,
@@ -482,944 +313,22 @@ private static UnifiedMod create(Activity activity, InbuiltModManager manager,
? mod -> PojavControls.launchEditor(activity)
: (ModIds.MORE_BUTTONS.equals(id) ? mod -> MoreButtonsEditor.show(activity) : null)
);
- result.setLocalConfigSchema(createLocalConfigSchema(activity, result));
+ result.setLocalConfigSchema(ModConfigSchema.createLocalConfigSchema(activity, result));
return result;
}
- private static RuntimeConfigSchema createLocalConfigSchema(Context context, UnifiedMod mod) {
- String modId = mod.getId();
- if (ModIds.ARMOR_HUD.equals(modId) || ModIds.CRYSTAL_OPTIMIZER.equals(modId)
- || ModIds.HIT_REGISTRATION.equals(modId) || ModIds.HIT_TIMING.equals(modId)
- || ModIds.HITBOX.equals(modId)) {
- return createCombatConfigSchema(context, mod);
- }
- if (ModIds.VOICE_CHAT.equals(modId)) {
- return createVoiceConfigSchema(context, mod);
- }
- if (ModIds.REACH_INDICATOR.equals(modId) || ModIds.TRAJECTORY_PREDICTION.equals(modId)
- || ModIds.HIT_PREDICTION.equals(modId) || ModIds.KILL_EFFECTS.equals(modId)) {
- return createPvpSuiteConfigSchema(context, mod);
- }
- if (ModIds.OFFHAND.equals(modId)) {
- return createOffhandConfigSchema(context, mod);
- }
- if (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.isFpsModule(modId)) {
- return createFpsConfigSchema(context, mod);
- }
- boolean hotbar = ModIds.HOTBAR_SLOT.equals(mod.getId());
- if (!hotbar && !ModIds.GYRO.equals(mod.getId())) return null;
- try {
- JSONArray categories = new JSONArray();
- JSONArray nodes = new JSONArray();
- if (hotbar) {
- categories.put(configCategory(context, "slots", R.string.mod_config_category_slots));
- categories.put(configCategory(context, "appearance", R.string.mod_config_category_appearance));
- categories.put(configCategory(context, "behavior", R.string.mod_config_category_behavior));
- nodes.put(configNode(context, mod, CFG_HOTBAR_ITEM_ICONS, "slots"));
- JSONArray slots = new JSONArray();
- for (int slot = 1; slot <= 9; slot++) {
- String key = hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_ENABLED);
- slots.put(new JSONObject().put("key", key).put("value", key)
- .put("label", mod.findConfigEntry(key).displayName));
- String section = "slot_" + slot;
- nodes.put(new JSONObject().put("id", section).put("type", "section")
- .put("category", "appearance").put("collapsible", true)
- .put("title", context.getString(R.string.mod_config_hotbar_slot_section, slot)));
- nodes.put(configNode(context, mod, hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_SIZE), "appearance")
- .put("section", section));
- nodes.put(configNode(context, mod, hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_OPACITY), "appearance")
- .put("section", section));
- }
- nodes.put(new JSONObject().put("id", "visible_slots").put("type", "toggle_group")
- .put("category", "slots").put("title", context.getString(R.string.mod_config_visible_slots))
- .put("options", slots));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "behavior"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
- } else {
- categories.put(configCategory(context, "motion", R.string.mod_config_category_motion));
- categories.put(configCategory(context, "button", R.string.mod_config_category_button));
- nodes.put(configNode(context, mod, CFG_GYRO_SENSITIVITY_X, "motion"));
- nodes.put(configNode(context, mod, CFG_GYRO_SENSITIVITY_Y, "motion"));
- nodes.put(configNode(context, mod, CFG_GYRO_INVERT_X, "motion"));
- nodes.put(configNode(context, mod, CFG_GYRO_INVERT_Y, "motion"));
- nodes.put(configNode(context, mod, CFG_GYRO_DEADZONE, "motion"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "button"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "button"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "button"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "button"));
- }
- return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
- .put("default_category", hotbar ? "slots" : "motion")
- .put("categories", categories).put("nodes", nodes).toString());
- } catch (JSONException e) {
- throw new IllegalStateException("Unable to build inbuilt config schema", e);
- }
- }
-
- /**
- * Category layout for the three combat modules. They each get an explicit schema so the
- * dialog groups related settings rather than listing one flat column.
- */
- private static RuntimeConfigSchema createCombatConfigSchema(Context context, UnifiedMod mod) {
- String modId = mod.getId();
- try {
- JSONArray categories = new JSONArray();
- JSONArray nodes = new JSONArray();
- String defaultCategory;
- if (ModIds.ARMOR_HUD.equals(modId)) {
- categories.put(configCategory(context, "display", R.string.mod_config_category_appearance));
- categories.put(configCategory(context, "data", R.string.mod_config_category_behavior));
- defaultCategory = "display";
- nodes.put(configNode(context, mod, CFG_ARMOR_HUD_SHOW_TARGET, "display"));
- nodes.put(configNode(context, mod, CFG_ARMOR_HUD_SHOW_ENCHANTS, "display"));
- nodes.put(configNode(context, mod, CFG_ARMOR_HUD_STACKED, "display"));
- nodes.put(configNode(context, mod, CFG_ARMOR_HUD_REFRESH_MS, "data"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "data"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "data"));
- } else if (ModIds.CRYSTAL_OPTIMIZER.equals(modId)) {
- categories.put(configCategory(context, "safety", R.string.mod_config_category_behavior));
- categories.put(configCategory(context, "tuning", R.string.mod_config_category_motion));
- defaultCategory = "safety";
- nodes.put(configNode(context, mod, CFG_CRYSTAL_MANUAL_ASSIST, "safety"));
- nodes.put(configNode(context, mod, CFG_CRYSTAL_MIN_SELF_HP, "safety"));
- nodes.put(configNode(context, mod, CFG_CRYSTAL_MAX_RANGE, "tuning"));
- nodes.put(configNode(context, mod, CFG_CRYSTAL_PLACEMENT_DELAY_MS, "tuning"));
- nodes.put(configNode(context, mod, CFG_CRYSTAL_KEYBIND, "tuning"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "tuning"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "tuning"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "safety"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "safety"));
- } else if (ModIds.HIT_TIMING.equals(modId)) {
- categories.put(configCategory(context, "timing", R.string.mod_config_category_motion));
- categories.put(configCategory(context, "display", R.string.mod_config_category_appearance));
- defaultCategory = "timing";
- nodes.put(configNode(context, mod, CFG_HIT_TIMING_COOLDOWN_MS, "timing"));
- nodes.put(configNode(context, mod, CFG_HIT_TIMING_SHOW_COMBO, "display"));
- nodes.put(configNode(context, mod, CFG_HIT_TIMING_SHOW_BAR, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "display"));
- } else if (ModIds.HITBOX.equals(modId)) {
- categories.put(configCategory(context, "entities", R.string.mod_config_category_slots));
- categories.put(configCategory(context, "guides", R.string.mod_config_category_appearance));
- defaultCategory = "entities";
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_PLAYERS, "entities"));
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_MOBS, "entities"));
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_ITEMS, "entities"));
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_PROJECTILES, "entities"));
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_THROWN_ITEMS, "entities"));
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_LOOK_LINE, "guides"));
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_TARGET_BOX, "guides"));
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_CRIT_LINE, "guides"));
- nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_COMBO_BOX, "guides"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "guides"));
- } else {
- categories.put(configCategory(context, "aim", R.string.mod_config_category_motion));
- categories.put(configCategory(context, "feedback", R.string.mod_config_category_button));
- defaultCategory = "aim";
- nodes.put(configNode(context, mod, CFG_HITREG_SENSITIVITY, "aim"));
- nodes.put(configNode(context, mod, CFG_HITREG_SMOOTHING, "aim"));
- nodes.put(configNode(context, mod, CFG_HITREG_PREDICTION, "aim"));
- nodes.put(configNode(context, mod, CFG_HITREG_HAPTIC, "feedback"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "feedback"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "feedback"));
- }
- int noteRes = scopeNoteRes(modId);
- if (noteRes != 0) {
- nodes.put(scopeNoteNode(context, mod, noteRes, defaultCategory));
- }
- return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
- .put("default_category", defaultCategory)
- .put("categories", categories).put("nodes", nodes).toString());
- } catch (JSONException e) {
- throw new IllegalStateException("Unable to build combat config schema", e);
- }
- }
-
- /**
- * Category layout for the proximity voice module: the microphone and the icon's appearance.
- *
- * Scope is deliberately trimmed to mic-only. Channel name, visibility, capacity and
- * per-member mute all live on the Voice tab now, so this dialog does not duplicate them -- a
- * control in two places is a control that can disagree with itself. What remains is global and
- * genuinely belongs to the module: whether the mic transmits at all, and how the in-world
- * nametag icon looks and behaves.
- *
- *
The scope note is placed in the default category so it is the first thing read -- the
- * module is named "Proximity Voice Chat" but only applies distance when a position feed
- * exists, and that distinction has to be on screen, not buried.
- */
- private static RuntimeConfigSchema createVoiceConfigSchema(Context context, UnifiedMod mod) {
- try {
- JSONArray categories = new JSONArray();
- JSONArray nodes = new JSONArray();
- categories.put(configCategory(context, "mic", R.string.mod_config_category_behavior));
- categories.put(configCategory(context, "icon", R.string.mod_config_category_appearance));
- categories.put(configCategory(context, "overlay", R.string.mod_config_category_button));
- nodes.put(configNode(context, mod, CFG_VOICE_MIC, "mic"));
- nodes.put(configNode(context, mod, CFG_VOICE_ICON_STYLE, "icon"));
- nodes.put(configNode(context, mod, CFG_VOICE_ICON_ANIMATE, "icon"));
- nodes.put(configNode(context, mod, CFG_VOICE_ICON_NAMETAG, "icon"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "overlay"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "overlay"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "overlay"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "overlay"));
- nodes.put(scopeNoteNode(context, mod, R.string.voice_chat_scope_note, "mic"));
- return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
- .put("default_category", "mic")
- .put("categories", categories).put("nodes", nodes).toString());
- } catch (JSONException e) {
- throw new IllegalStateException("Unable to build voice config schema", e);
- }
- }
-
- /**
- * Category layout for the four PvP Suite modules (V1.1).
- *
- *
Each gets a short, focused dialog: the module's own display options plus the shared
- * "show everywhere" switch, and an info node carrying its scope note. The note is deliberate -
- * every module in the suite is named after an outcome (Reach Indicator, Hit Prediction) but
- * only works from the peer feed this build can actually reach, so the dialog says what it can
- * and cannot see rather than leaving the limit to read as a broken feature.
- */
- private static RuntimeConfigSchema createFpsConfigSchema(Context context, UnifiedMod mod) {
- org.chimeramc.client.core.mods.fps.FpsModuleCatalog.Module module =
- org.chimeramc.client.core.mods.fps.FpsModuleCatalog.find(mod.getId());
- if (module == null) return null;
- try {
- JSONArray categories = new JSONArray();
- JSONArray nodes = new JSONArray();
- // The three FPS modules keep only their implemented options, so a category is only
- // created when an option actually uses it.
- categories.put(configCategory(context, "main", R.string.mod_config_category_culling));
- categories.put(configCategory(context, "batching", R.string.mod_config_category_batching));
- categories.put(configCategory(context, "memory", R.string.mod_config_category_memory));
- categories.put(configCategory(context, "timing", R.string.mod_config_category_timing));
- categories.put(configCategory(context, "network", R.string.mod_config_category_network));
-
- nodes.put(scopeNoteNode(context, mod, R.string.fps_modules_scope_note, "main"));
-
- for (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.Option option : module.options) {
- JSONObject node = new JSONObject()
- .put("id", option.key)
- .put("key", option.key)
- .put("category", fpsCategoryFor(option.key))
- .put("title", option.label)
- .put("type", option.isToggle() ? "toggle" : "slider")
- .put("default_value", option.defaultValue)
- .put("min_value", String.valueOf(option.min))
- .put("max_value", String.valueOf(option.max))
- .put("description", option.description);
- nodes.put(node);
- }
- return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
- .put("default_category", "main")
- .put("categories", categories).put("nodes", nodes).toString());
- } catch (JSONException e) {
- throw new IllegalStateException("Unable to build FPS module config schema", e);
- }
- }
-
- /** The category a kept FPS option belongs to. Only the seven implemented options are present. */
- private static String fpsCategoryFor(String key) {
- if (key.startsWith("tr_")) {
- return key.contains("net") || key.contains("coalesce") ? "network" : "timing";
- }
- if (key.startsWith("vc_")) {
- return key.contains("atlas") ? "memory" : "batching";
- }
- return "main";
- }
-
- private static RuntimeConfigSchema createPvpSuiteConfigSchema(Context context, UnifiedMod mod) {
- String modId = mod.getId();
- try {
- JSONArray categories = new JSONArray();
- JSONArray nodes = new JSONArray();
- categories.put(configCategory(context, "display", R.string.mod_config_category_appearance));
- categories.put(configCategory(context, "behavior", R.string.mod_config_category_behavior));
- if (ModIds.REACH_INDICATOR.equals(modId)) {
- nodes.put(configNode(context, mod, CFG_REACH_POSITION, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
- } else if (ModIds.TRAJECTORY_PREDICTION.equals(modId)) {
- nodes.put(configNode(context, mod, CFG_TRAJECTORY_COLOR, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
- } else if (ModIds.HIT_PREDICTION.equals(modId)) {
- nodes.put(configNode(context, mod, CFG_HIT_PREDICTION_LOOKAHEAD, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
- } else {
- nodes.put(configNode(context, mod, CFG_KILL_EFFECT_STYLE, "display"));
- nodes.put(configNode(context, mod, CFG_KILL_EFFECT_COLOR, "display"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
- }
- int noteRes = scopeNoteRes(modId);
- if (noteRes != 0) {
- nodes.put(scopeNoteNode(context, mod, noteRes, "display"));
- }
- return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
- .put("default_category", "display")
- .put("categories", categories).put("nodes", nodes).toString());
- } catch (JSONException e) {
- throw new IllegalStateException("Unable to build PvP suite config schema", e);
- }
- }
-
- /**
- * Category layout for the Offhand module.
- *
- *
Two categories: what the on-screen button does (mode + overlay look) and the hardware
- * keybinds. The scope note is in the default category because the module is honest about being
- * a key-sender — Bedrock resolves the swap/use, so it works everywhere the game does.
- */
- private static RuntimeConfigSchema createOffhandConfigSchema(Context context, UnifiedMod mod) {
- try {
- JSONArray categories = new JSONArray();
- JSONArray nodes = new JSONArray();
- categories.put(configCategory(context, "action", R.string.mod_config_category_behavior));
- categories.put(configCategory(context, "keys", R.string.mod_config_category_button));
- categories.put(configCategory(context, "overlay", R.string.mod_config_category_appearance));
- nodes.put(configNode(context, mod, CFG_OFFHAND_MODE, "action"));
- nodes.put(configNode(context, mod, CFG_OFFHAND_INVENTORY_KEY, "keys"));
- nodes.put(configNode(context, mod, CFG_OFFHAND_USE_KEY, "keys"));
- nodes.put(configNode(context, mod, CFG_OFFHAND_KEYBIND, "keys"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "overlay"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "overlay"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "overlay"));
- nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "overlay"));
- nodes.put(scopeNoteNode(context, mod, R.string.offhand_scope_note, "action"));
- return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
- .put("default_category", "action")
- .put("categories", categories).put("nodes", nodes).toString());
- } catch (JSONException e) {
- throw new IllegalStateException("Unable to build offhand config schema", e);
- }
- }
-
- private static JSONObject configCategory(Context context, String id, int titleRes) throws JSONException {
- return new JSONObject().put("id", id).put("title", context.getString(titleRes));
- }
-
- private static JSONObject configNode(Context context, UnifiedMod mod, String key, String category) throws JSONException {
- UnifiedMod.ConfigEntry config = mod.findConfigEntry(key);
- JSONObject node = new JSONObject().put("id", key).put("key", key)
- .put("category", category).put("title", config.displayName)
- .put("type", schemaTypeFor(config.type))
- .put("default_value", config.defaultValue)
- .put("min_value", config.minValue).put("max_value", config.maxValue);
- // A choice node needs its options in the schema: the dialog builds the control from the
- // node alone, so a RADIO without options renders an empty selector. The labels live in
- // min_value and the values are their indices, matching the legacy config renderer.
- if (config.type == UnifiedMod.ConfigType.RADIO) {
- JSONArray options = new JSONArray();
- String[] labels = config.minValue != null ? config.minValue.split(",") : new String[0];
- for (int i = 0; i < labels.length; i++) {
- options.put(new JSONObject().put("value", String.valueOf(i))
- .put("label", labels[i].trim()));
- }
- node.put("options", options);
- }
- int descRes = configDescriptionRes(key);
- if (descRes != 0) {
- node.put("description", context.getString(descRes));
- }
- if (!config.dependsOn.isEmpty()) {
- node.put("enabled_when", new JSONArray().put(new JSONObject()
- .put("key", config.dependsOn).put("op", "truthy")));
- }
- // An output the native entity feed would have to supply is disabled rather than hidden:
- // the switch stays visible so the option is discoverable, but it cannot be turned on to
- // no effect. The same rule the module-level badge uses, one level down.
- if (ModIds.outputRequiresGameData(mod.getId(), key)) {
- node.put("disabled", true);
- }
- return node;
- }
-
- /**
- * The `_desc` string for a config key, or 0 when there is none.
- *
- *
Resolved through an explicit table rather than a name lookup so a renamed key cannot
- * silently start matching an unrelated string.
- */
- static int configDescriptionRes(String key) {
- switch (key) {
- case CFG_ARMOR_HUD_SHOW_TARGET: return R.string.mod_config_armor_hud_show_target_desc;
- case CFG_ARMOR_HUD_SHOW_ENCHANTS: return R.string.mod_config_armor_hud_show_enchants_desc;
- case CFG_ARMOR_HUD_STACKED: return R.string.mod_config_armor_hud_stacked_desc;
- case CFG_ARMOR_HUD_REFRESH_MS: return R.string.mod_config_armor_hud_refresh_ms_desc;
- case CFG_HITREG_SENSITIVITY: return R.string.mod_config_hitreg_sensitivity_desc;
- case CFG_HITREG_SMOOTHING: return R.string.mod_config_hitreg_smoothing_desc;
- case CFG_HITREG_PREDICTION: return R.string.mod_config_hitreg_prediction_desc;
- case CFG_HITREG_HAPTIC: return R.string.mod_config_hitreg_haptic_desc;
- case CFG_CRYSTAL_PLACEMENT_DELAY_MS: return R.string.mod_config_crystal_placement_delay_ms_desc;
- case CFG_CRYSTAL_MANUAL_ASSIST: return R.string.mod_config_crystal_manual_assist_desc;
- case CFG_CRYSTAL_MIN_SELF_HP: return R.string.mod_config_crystal_min_self_hp_desc;
- case CFG_CRYSTAL_MAX_RANGE: return R.string.mod_config_crystal_max_range_desc;
- case CFG_CRYSTAL_KEYBIND: return R.string.mod_config_crystal_keybind_desc;
- case CFG_HIT_TIMING_COOLDOWN_MS: return R.string.mod_config_hit_timing_cooldown_ms_desc;
- case CFG_HIT_TIMING_SHOW_COMBO: return R.string.mod_config_hit_timing_show_combo_desc;
- case CFG_HIT_TIMING_SHOW_BAR: return R.string.mod_config_hit_timing_show_bar_desc;
- case CFG_HITBOX_SHOW_PLAYERS: return R.string.mod_config_hitbox_show_players_desc;
- case CFG_HITBOX_SHOW_MOBS: return R.string.mod_config_hitbox_show_mobs_desc;
- case CFG_HITBOX_SHOW_ITEMS: return R.string.mod_config_hitbox_show_items_desc;
- case CFG_HITBOX_SHOW_PROJECTILES: return R.string.mod_config_hitbox_show_projectiles_desc;
- case CFG_HITBOX_SHOW_THROWN_ITEMS: return R.string.mod_config_hitbox_show_thrown_items_desc;
- case CFG_HITBOX_SHOW_LOOK_LINE: return R.string.mod_config_hitbox_show_look_line_desc;
- case CFG_HITBOX_SHOW_CRIT_LINE: return R.string.mod_config_hitbox_show_crit_line_desc;
- case CFG_HITBOX_SHOW_COMBO_BOX: return R.string.mod_config_hitbox_show_combo_box_desc;
- case CFG_HITBOX_SHOW_TARGET_BOX: return R.string.mod_config_hitbox_show_target_box_desc;
- case CFG_VOICE_RANGE: return R.string.mod_config_voice_range_desc;
- case CFG_VOICE_VOLUME: return R.string.mod_config_voice_volume_desc;
- case CFG_VOICE_CHANNEL: return R.string.mod_config_voice_channel_desc;
- case CFG_VOICE_MIC: return R.string.mod_config_voice_mic_desc;
- case CFG_VOICE_ICON_STYLE: return R.string.mod_config_voice_icon_style_desc;
- case CFG_VOICE_ICON_ANIMATE: return R.string.mod_config_voice_icon_animate_desc;
- case CFG_VOICE_ICON_NAMETAG: return R.string.mod_config_voice_icon_nametag_desc;
- case CFG_REACH_POSITION: return R.string.mod_config_reach_position_desc;
- case CFG_TRAJECTORY_COLOR: return R.string.mod_config_trajectory_color_desc;
- case CFG_HIT_PREDICTION_LOOKAHEAD: return R.string.mod_config_hit_prediction_lookahead_desc;
- case CFG_KILL_EFFECT_STYLE: return R.string.mod_config_kill_effect_style_desc;
- case CFG_KILL_EFFECT_COLOR: return R.string.mod_config_kill_effect_color_desc;
- default: return 0;
- }
- }
-
- /**
- * An informational node carrying the module's scope note, or null when it has none.
- *
- *
These modules are named after an outcome ("Hit Registration") but deliberately stop
- * short of it — the server decides whether a hit lands, and the box feed may not exist yet.
- * Saying so in the dialog is what stops the honest limit reading as a broken feature.
- */
- private static JSONObject scopeNoteNode(Context context, UnifiedMod mod,
- int noteRes, String category) throws JSONException {
- return new JSONObject()
- .put("id", mod.getId() + "_scope_note")
- .put("type", "info")
- .put("category", category)
- .put("title", context.getString(R.string.mod_config_scope_note_title))
- .put("description", context.getString(noteRes));
- }
-
- private static int scopeNoteRes(String modId) {
- if (ModIds.HIT_REGISTRATION.equals(modId)) return R.string.hitreg_scope_note;
- if (ModIds.CRYSTAL_OPTIMIZER.equals(modId)) return R.string.crystal_optimizer_fairness_note;
- if (ModIds.HIT_TIMING.equals(modId)) return R.string.hit_timing_scope_note;
- if (ModIds.HITBOX.equals(modId)) return R.string.hitbox_scope_note;
- if (ModIds.ARMOR_HUD.equals(modId)) return R.string.armor_hud_no_data;
- if (ModIds.VOICE_CHAT.equals(modId)) return R.string.voice_chat_scope_note;
- if (ModIds.REACH_INDICATOR.equals(modId)) return R.string.reach_indicator_scope_note;
- if (ModIds.TRAJECTORY_PREDICTION.equals(modId)) return R.string.trajectory_prediction_scope_note;
- if (ModIds.HIT_PREDICTION.equals(modId)) return R.string.hit_prediction_scope_note;
- if (ModIds.KILL_EFFECTS.equals(modId)) return R.string.kill_effects_scope_note;
- if (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.isFpsModule(modId)) {
- return R.string.fps_modules_scope_note;
- }
- return 0;
- }
-
- /**
- * Maps a config entry's type to the schema node type the dialog renders.
- *
- * This must be the real type, not a toggle/slider default: the dialog builds its control
- * from the node type alone, so a KEYBIND node declared as a slider would show a 0..100
- * range and write a small integer over the stored key code, and a RADIO node would lose
- * its options. Anything the schema cannot express (the legacy colour picker) still falls
- * back to a slider rather than silently dropping the setting.
- */
- static String schemaTypeFor(UnifiedMod.ConfigType type) {
- switch (type) {
- case TOGGLE:
- return "toggle";
- case SLIDER_INT:
- return "slider_int";
- case SLIDER_FLOAT:
- return "slider_float";
- case RADIO:
- return "choice";
- case KEYBIND:
- return "keybind";
- case TEXT:
- return "text";
- case BUTTON:
- return "button";
- case COLOR:
- default:
- return "slider_int";
- }
- }
-
- private static List createConfigs(Context context,
- InbuiltModManager manager,
- String modId) {
- List configs = new ArrayList<>();
- if (ModIds.POJAV_CONTROLS.equals(modId) || ModIds.MORE_BUTTONS.equals(modId)) return configs;
- if (!ModIds.CHICK_PET.equals(modId)) {
- if (!ModIds.HOTBAR_SLOT.equals(modId)) {
- configs.add(config(CFG_OVERLAY_SIZE,
- context.getString(R.string.mod_config_overlay_button_size_dp),
- UnifiedMod.ConfigType.SLIDER_INT,
- "56", "20", "100",
- String.valueOf(manager.getOverlayButtonSize(modId))));
- configs.add(config(CFG_OVERLAY_OPACITY,
- context.getString(R.string.mod_config_overlay_opacity_percent),
- UnifiedMod.ConfigType.SLIDER_INT,
- "100", "0", "100",
- String.valueOf(manager.getOverlayOpacity(modId))));
- }
- configs.add(config(CFG_OVERLAY_LOCK,
- context.getString(R.string.overlay_button_lock),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isOverlayLocked(modId))));
- configs.add(config(CFG_OVERLAY_SHOW_EVERYWHERE,
- context.getString(R.string.mod_config_overlay_show_everywhere),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isOverlayShowEverywhere(modId))));
- }
-
- // The frame-time graph is an FPS-overlay-only option: it needs the FPS meter,
- // which only that overlay runs.
- if (ModIds.FPS_DISPLAY.equals(modId)) {
- configs.add(config(CFG_FRAME_GRAPH,
- context.getString(R.string.mod_config_fps_frame_graph),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isFrameTimeGraphEnabled(modId))));
- configs.add(config(CFG_FPS_STATS,
- context.getString(R.string.mod_config_fps_stats),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isFpsStatsEnabled(modId))));
- configs.add(config(CFG_FPS_STATS_WINDOW,
- context.getString(R.string.mod_config_fps_stats_window),
- UnifiedMod.ConfigType.SLIDER_INT,
- String.valueOf(InbuiltModManager.FPS_STATS_WINDOW_DEFAULT),
- String.valueOf(InbuiltModManager.FPS_STATS_WINDOW_MIN),
- String.valueOf(InbuiltModManager.FPS_STATS_WINDOW_MAX),
- String.valueOf(manager.getFpsStatsWindow(modId))));
- }
-
- if (ModIds.HOTBAR_SLOT.equals(modId)) {
- configs.add(config(CFG_HOTBAR_ITEM_ICONS,
- context.getString(R.string.mod_config_hotbar_item_icons),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isHotbarItemIconsEnabled())));
- for (int slot = 1; slot <= 9; slot++) {
- String enabledKey = hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_ENABLED);
- String overlayKey = ModIds.HOTBAR_SLOT + ":" + slot;
- configs.add(config(enabledKey,
- context.getString(R.string.mod_config_hotbar_slot_enabled, slot),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHotbarSlotEnabled(slot))));
- configs.add(config(hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_SIZE),
- context.getString(R.string.mod_config_hotbar_slot_size, slot),
- UnifiedMod.ConfigType.SLIDER_INT,
- "56", "20", "100",
- String.valueOf(manager.getOverlayButtonSize(overlayKey)), enabledKey));
- configs.add(config(hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_OPACITY),
- context.getString(R.string.mod_config_hotbar_slot_opacity, slot),
- UnifiedMod.ConfigType.SLIDER_INT,
- "100", "0", "100",
- String.valueOf(manager.getOverlayOpacity(overlayKey)), enabledKey));
- }
- }
-
- if (ModIds.OFFHAND.equals(modId)) {
- configs.add(config(CFG_OFFHAND_MODE,
- context.getString(R.string.mod_config_offhand_mode),
- UnifiedMod.ConfigType.RADIO,
- context.getString(R.string.mod_config_offhand_mode_labels), "", "",
- String.valueOf(manager.getOffhandMode())));
- configs.add(config(CFG_OFFHAND_INVENTORY_KEY,
- context.getString(R.string.mod_config_offhand_inventory_key),
- UnifiedMod.ConfigType.KEYBIND,
- "", "", "",
- String.valueOf(manager.getOffhandInventoryKey())));
- configs.add(config(CFG_OFFHAND_USE_KEY,
- context.getString(R.string.mod_config_offhand_use_key),
- UnifiedMod.ConfigType.KEYBIND,
- "", "", "",
- String.valueOf(manager.getOffhandUseKey())));
- configs.add(config(CFG_OFFHAND_KEYBIND,
- context.getString(R.string.mod_config_offhand_keybind),
- UnifiedMod.ConfigType.KEYBIND,
- "", "", "",
- String.valueOf(manager.getOffhandKeybind())));
- }
-
- if (ModIds.AUTO_SPRINT.equals(modId)) {
- configs.add(config(CFG_AUTO_SPRINT_KEYBIND,
- context.getString(R.string.mod_config_auto_sprint_keybind),
- UnifiedMod.ConfigType.KEYBIND,
- "", "", "",
- String.valueOf(manager.getAutoSprintKeybind())));
- } else if (ModIds.VIRTUAL_CURSOR.equals(modId)) {
- configs.add(config(CFG_CURSOR_SENSITIVITY,
- context.getString(R.string.mod_config_cursor_sensitivity_percent),
- UnifiedMod.ConfigType.SLIDER_INT,
- "120", "10", "200",
- String.valueOf(manager.getCursorSensitivity())));
- } else if (ModIds.ZOOM.equals(modId)) {
- // The keybind is always offered: it is the input that would drive zoom once a backend
- // provides one. The four shaping sliders are hidden while no backend advertises
- // Feature.ZOOM, because they change a field-of-view zoom nothing can apply — offering
- // them would be four controls that visibly do nothing.
- configs.add(config(CFG_ZOOM_KEYBIND,
- context.getString(R.string.mod_config_zoom_keybind),
- UnifiedMod.ConfigType.KEYBIND,
- "", "", "",
- String.valueOf(manager.getZoomKeybind())));
- if (ModIds.requiresBackendFeature(ModIds.ZOOM)) {
- return configs;
- }
- configs.add(config(CFG_ZOOM_LEVEL,
- context.getString(R.string.mod_config_zoom_level_percent),
- UnifiedMod.ConfigType.SLIDER_INT,
- "10", "-20", "100",
- String.valueOf(manager.getZoomLevel())));
- configs.add(config(CFG_ZOOM_TRANSITION,
- context.getString(R.string.mod_config_zoom_transition),
- UnifiedMod.ConfigType.SLIDER_INT,
- "150", "0", "1000",
- String.valueOf(manager.getZoomTransitionDuration())));
- configs.add(config(CFG_ZOOM_SCROLL_SENSITIVITY,
- context.getString(R.string.mod_config_zoom_scroll_sensitivity),
- UnifiedMod.ConfigType.SLIDER_INT,
- String.valueOf(100),
- String.valueOf(InbuiltModManager.MIN_ZOOM_SCROLL_SENSITIVITY),
- String.valueOf(InbuiltModManager.MAX_ZOOM_SCROLL_SENSITIVITY),
- String.valueOf(manager.getZoomScrollSensitivity())));
- configs.add(config(CFG_ZOOM_SMOOTHING,
- context.getString(R.string.mod_config_zoom_smoothing),
- UnifiedMod.ConfigType.SLIDER_INT,
- "0", "0",
- String.valueOf(InbuiltModManager.MAX_ZOOM_SMOOTHING),
- String.valueOf(manager.getZoomSmoothing())));
- } else if (ModIds.SNAPLOOK.equals(modId)) {
- configs.add(config(CFG_SNAPLOOK_KEYBIND,
- context.getString(R.string.mod_config_snaplook_keybind),
- UnifiedMod.ConfigType.KEYBIND,
- "", "", "",
- String.valueOf(manager.getSnaplookKeybind())));
- configs.add(config(CFG_SNAPLOOK_RETURN_SPEED,
- context.getString(R.string.mod_config_snaplook_return_speed),
- UnifiedMod.ConfigType.SLIDER_INT,
- "50", "0",
- String.valueOf(InbuiltModManager.MAX_SNAPLOOK_RETURN_SPEED),
- String.valueOf(manager.getSnaplookReturnSpeed())));
- } else if (ModIds.GYRO.equals(modId)) {
- configs.add(config(CFG_GYRO_SENSITIVITY_X,
- context.getString(R.string.mod_config_gyro_sensitivity_x),
- UnifiedMod.ConfigType.SLIDER_INT,
- "100", "10", "300",
- String.valueOf(manager.getGyroSensitivityX())));
- configs.add(config(CFG_GYRO_SENSITIVITY_Y,
- context.getString(R.string.mod_config_gyro_sensitivity_y),
- UnifiedMod.ConfigType.SLIDER_INT,
- "100", "10", "300",
- String.valueOf(manager.getGyroSensitivityY())));
- configs.add(config(CFG_GYRO_INVERT_X,
- context.getString(R.string.mod_config_gyro_invert_x),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isGyroInvertX())));
- configs.add(config(CFG_GYRO_INVERT_Y,
- context.getString(R.string.mod_config_gyro_invert_y),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isGyroInvertY())));
- configs.add(config(CFG_GYRO_DEADZONE,
- context.getString(R.string.mod_config_gyro_deadzone),
- UnifiedMod.ConfigType.SLIDER_INT,
- "5", "0", "50",
- String.valueOf(manager.getGyroDeadzone())));
- } else if (ModIds.AIM_SETTINGS.equals(modId)) {
- configs.add(config(CFG_AIM_SENSITIVITY,
- context.getString(R.string.mod_config_aim_sensitivity),
- UnifiedMod.ConfigType.SLIDER_INT,
- "100", "10", "300",
- String.valueOf(manager.getAimSensitivity())));
- configs.add(config(CFG_AIM_SMOOTHING,
- context.getString(R.string.mod_config_aim_smoothing),
- UnifiedMod.ConfigType.SLIDER_INT,
- "40", "0", "95",
- String.valueOf(manager.getAimSmoothing())));
- configs.add(config(CFG_AIM_CROSSHAIR,
- context.getString(R.string.mod_config_aim_crosshair),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isAimCrosshairEnabled())));
- configs.add(config(CFG_AIM_CROSSHAIR_STYLE,
- context.getString(R.string.aim_crosshair_style),
- UnifiedMod.ConfigType.RADIO,
- "1",
- context.getString(R.string.aim_crosshair_style_dot) + ","
- + context.getString(R.string.aim_crosshair_style_cross) + ","
- + context.getString(R.string.aim_crosshair_style_circle),
- "",
- String.valueOf(manager.getAimCrosshairStyle())));
- configs.add(config(CFG_AIM_CROSSHAIR_COLOR,
- context.getString(R.string.aim_crosshair_color),
- UnifiedMod.ConfigType.COLOR,
- "#FF3DDC84", "", "",
- colorToHex(manager.getAimCrosshairColor())));
- configs.add(config(CFG_AIM_FLASH,
- context.getString(R.string.mod_config_aim_flash),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isAimFlashEnabled())));
- } else if (ModIds.ARMOR_HUD.equals(modId)) {
- configs.add(config(CFG_ARMOR_HUD_SHOW_TARGET,
- context.getString(R.string.mod_config_armor_hud_show_target),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isArmorHudShowTarget())));
- configs.add(config(CFG_ARMOR_HUD_SHOW_ENCHANTS,
- context.getString(R.string.mod_config_armor_hud_show_enchants),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.isArmorHudShowEnchants())));
- configs.add(config(CFG_ARMOR_HUD_STACKED,
- context.getString(R.string.mod_config_armor_hud_stacked),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isArmorHudStacked())));
- configs.add(config(CFG_ARMOR_HUD_REFRESH_MS,
- context.getString(R.string.mod_config_armor_hud_refresh_ms),
- UnifiedMod.ConfigType.SLIDER_INT,
- "100", "50", "1000",
- String.valueOf(manager.getArmorHudRefreshMs())));
- } else if (ModIds.CRYSTAL_OPTIMIZER.equals(modId)) {
- // Manual assist is listed first and defaults on: it is the variant that does not
- // automate a player action, so it must be the one a fresh profile lands on.
- configs.add(config(CFG_CRYSTAL_MANUAL_ASSIST,
- context.getString(R.string.mod_config_crystal_manual_assist),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isCrystalManualAssist())));
- configs.add(config(CFG_CRYSTAL_MIN_SELF_HP,
- context.getString(R.string.mod_config_crystal_min_self_hp),
- UnifiedMod.ConfigType.SLIDER_INT,
- "14", "1", "20",
- String.valueOf(manager.getCrystalMinSelfHp())));
- configs.add(config(CFG_CRYSTAL_MAX_RANGE,
- context.getString(R.string.mod_config_crystal_max_range),
- UnifiedMod.ConfigType.SLIDER_INT,
- "4", "1", "8",
- String.valueOf(manager.getCrystalMaxRange())));
- configs.add(config(CFG_CRYSTAL_PLACEMENT_DELAY_MS,
- context.getString(R.string.mod_config_crystal_placement_delay_ms),
- UnifiedMod.ConfigType.SLIDER_INT,
- "50", "0", "1000",
- String.valueOf(manager.getCrystalPlacementDelayMs())));
- configs.add(config(CFG_CRYSTAL_KEYBIND,
- context.getString(R.string.mod_config_crystal_keybind),
- UnifiedMod.ConfigType.KEYBIND,
- "", "", "",
- String.valueOf(manager.getCrystalKeybind())));
- } else if (ModIds.HIT_REGISTRATION.equals(modId)) {
- configs.add(config(CFG_HITREG_SENSITIVITY,
- context.getString(R.string.mod_config_hitreg_sensitivity),
- UnifiedMod.ConfigType.SLIDER_INT,
- "100", "10", "300",
- String.valueOf(manager.getHitRegSensitivity())));
- configs.add(config(CFG_HITREG_SMOOTHING,
- context.getString(R.string.mod_config_hitreg_smoothing),
- UnifiedMod.ConfigType.SLIDER_INT,
- "25", "0", "95",
- String.valueOf(manager.getHitRegSmoothing())));
- configs.add(config(CFG_HITREG_PREDICTION,
- context.getString(R.string.mod_config_hitreg_prediction),
- UnifiedMod.ConfigType.SLIDER_INT,
- "35", "0", "100",
- String.valueOf(manager.getHitRegPrediction())));
- configs.add(config(CFG_HITREG_HAPTIC,
- context.getString(R.string.mod_config_hitreg_haptic),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitRegHapticEnabled())));
- } else if (ModIds.HIT_TIMING.equals(modId)) {
- configs.add(config(CFG_HIT_TIMING_COOLDOWN_MS,
- context.getString(R.string.mod_config_hit_timing_cooldown_ms),
- UnifiedMod.ConfigType.SLIDER_INT,
- String.valueOf(HitTimingSolver.DEFAULT_COOLDOWN_MS), "50", "2000",
- String.valueOf(manager.getHitTimingCooldownMs())));
- configs.add(config(CFG_HIT_TIMING_SHOW_COMBO,
- context.getString(R.string.mod_config_hit_timing_show_combo),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitTimingShowCombo())));
- configs.add(config(CFG_HIT_TIMING_SHOW_BAR,
- context.getString(R.string.mod_config_hit_timing_show_bar),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitTimingShowTimingBar())));
- } else if (ModIds.HITBOX.equals(modId)) {
- configs.add(config(CFG_HITBOX_SHOW_PLAYERS,
- context.getString(R.string.mod_config_hitbox_show_players),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowPlayers())));
- configs.add(config(CFG_HITBOX_SHOW_MOBS,
- context.getString(R.string.mod_config_hitbox_show_mobs),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowMobs())));
- configs.add(config(CFG_HITBOX_SHOW_ITEMS,
- context.getString(R.string.mod_config_hitbox_show_items),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowItems())));
- configs.add(config(CFG_HITBOX_SHOW_PROJECTILES,
- context.getString(R.string.mod_config_hitbox_show_projectiles),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowProjectiles())));
- configs.add(config(CFG_HITBOX_SHOW_THROWN_ITEMS,
- context.getString(R.string.mod_config_hitbox_show_thrown_items),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowThrownItems())));
- configs.add(config(CFG_HITBOX_SHOW_LOOK_LINE,
- context.getString(R.string.mod_config_hitbox_show_look_line),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowLookLine())));
- configs.add(config(CFG_HITBOX_SHOW_CRIT_LINE,
- context.getString(R.string.mod_config_hitbox_show_crit_line),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowCritLine())));
- configs.add(config(CFG_HITBOX_SHOW_COMBO_BOX,
- context.getString(R.string.mod_config_hitbox_show_combo_box),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowComboBox())));
- configs.add(config(CFG_HITBOX_SHOW_TARGET_BOX,
- context.getString(R.string.mod_config_hitbox_show_target_box),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isHitboxShowTargetBox())));
- } else if (ModIds.REACH_INDICATOR.equals(modId)) {
- configs.add(config(CFG_REACH_POSITION,
- context.getString(R.string.mod_config_reach_position),
- UnifiedMod.ConfigType.RADIO,
- "0",
- context.getString(R.string.mod_config_reach_position_below) + ","
- + context.getString(R.string.mod_config_reach_position_hotbar),
- "",
- String.valueOf(manager.getReachIndicatorPosition())));
- } else if (ModIds.TRAJECTORY_PREDICTION.equals(modId)) {
- configs.add(config(CFG_TRAJECTORY_COLOR,
- context.getString(R.string.mod_config_trajectory_color),
- UnifiedMod.ConfigType.COLOR,
- "#FF7CFC5A", "", "",
- colorToHex(manager.getTrajectoryColor())));
- } else if (ModIds.HIT_PREDICTION.equals(modId)) {
- configs.add(config(CFG_HIT_PREDICTION_LOOKAHEAD,
- context.getString(R.string.mod_config_hit_prediction_lookahead),
- UnifiedMod.ConfigType.SLIDER_INT,
- String.valueOf(org.chimeramc.client.core.mods.inbuilt.overlay.HitPredictor.DEFAULT_LOOK_AHEAD_MS),
- String.valueOf(org.chimeramc.client.core.mods.inbuilt.overlay.HitPredictor.MIN_LOOK_AHEAD_MS),
- String.valueOf(org.chimeramc.client.core.mods.inbuilt.overlay.HitPredictor.MAX_LOOK_AHEAD_MS),
- String.valueOf(manager.getHitPredictionLookAheadMs())));
- } else if (ModIds.KILL_EFFECTS.equals(modId)) {
- configs.add(config(CFG_KILL_EFFECT_STYLE,
- context.getString(R.string.mod_config_kill_effect_style),
- UnifiedMod.ConfigType.RADIO,
- "0",
- context.getString(R.string.kill_effect_style_burst) + ","
- + context.getString(R.string.kill_effect_style_column) + ","
- + context.getString(R.string.kill_effect_style_ring),
- "",
- String.valueOf(manager.getKillEffectStyle())));
- configs.add(config(CFG_KILL_EFFECT_COLOR,
- context.getString(R.string.mod_config_kill_effect_color),
- UnifiedMod.ConfigType.COLOR,
- "#FFFFC24B", "", "",
- colorToHex(manager.getKillEffectColor())));
- } else if (ModIds.VOICE_CHAT.equals(modId)) {
- // Trimmed to mic-only scope: the mic master switch plus the in-world icon's look and
- // behaviour. Channel, visibility, capacity and per-member mute are owned by the Voice
- // tab, so they are deliberately absent here to avoid a second source of truth.
- configs.add(config(CFG_VOICE_MIC,
- context.getString(R.string.mod_config_voice_mic),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isVoiceMicEnabled())));
- configs.add(config(CFG_VOICE_ICON_STYLE,
- context.getString(R.string.mod_config_voice_icon_style),
- UnifiedMod.ConfigType.TOGGLE,
- "false", "", "",
- String.valueOf(manager.getVoiceIconStyle()
- == org.chimeramc.client.core.mods.inbuilt.overlay.MicIconStyle.STYLE_SMOOTH)));
- configs.add(config(CFG_VOICE_ICON_ANIMATE,
- context.getString(R.string.mod_config_voice_icon_animate),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isVoiceIconAnimated())));
- configs.add(config(CFG_VOICE_ICON_NAMETAG,
- context.getString(R.string.mod_config_voice_icon_nametag),
- UnifiedMod.ConfigType.TOGGLE,
- "true", "", "",
- String.valueOf(manager.isVoiceNametagIconEnabled())));
- }
- if (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.isFpsModule(modId)) {
- // The FPS options come straight from the catalogue, so the dialog, the defaults and the
- // stored values read one source. A missing stored value reads the catalogue default.
- org.chimeramc.client.core.mods.fps.FpsModuleCatalog.Module module =
- org.chimeramc.client.core.mods.fps.FpsModuleCatalog.find(modId);
- for (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.Option option : module.options) {
- if (option.isToggle()) {
- boolean current = manager.getModConfigBool(option.key,
- Boolean.parseBoolean(option.defaultValue));
- configs.add(config(option.key, option.label, UnifiedMod.ConfigType.TOGGLE,
- option.defaultValue, "", "", String.valueOf(current)));
- } else {
- int def = Integer.parseInt(option.defaultValue);
- int current = manager.getModConfigInt(option.key, def);
- configs.add(config(option.key, option.label, UnifiedMod.ConfigType.SLIDER_INT,
- option.defaultValue, String.valueOf(option.min),
- String.valueOf(option.max), String.valueOf(current)));
- }
- }
- }
- return configs;
- }
-
- private static UnifiedMod.ConfigEntry config(String key, String displayName,
- UnifiedMod.ConfigType type,
- String defaultValue, String minValue,
- String maxValue, String currentValue) {
- return config(key, displayName, type, defaultValue, minValue, maxValue, currentValue, "");
- }
-
- private static UnifiedMod.ConfigEntry config(String key, String displayName,
- UnifiedMod.ConfigType type,
- String defaultValue, String minValue,
- String maxValue, String currentValue,
- String dependsOn) {
- return new UnifiedMod.ConfigEntry(
- key, displayName, type, defaultValue, minValue, maxValue,
- currentValue, dependsOn
- );
- }
-
- private static String hotbarSlotConfigKey(int slot, String setting) {
- return CFG_HOTBAR_SLOT_PREFIX + slot + "_" + setting;
- }
-
private static void setEnabled(InbuiltModManager manager, UnifiedMod mod, boolean enabled) {
manager.setInbuiltModEnabled(mod.getId(), enabled);
InbuiltOverlayManager overlayManager = InbuiltOverlayManager.getInstance();
if (overlayManager != null) {
overlayManager.handleModToggle(mod.getId(), enabled);
}
+ if (org.chimeramc.client.core.mods.render.PresentFrameCatalog
+ .isPresentFrameModule(mod.getId())) {
+ // A toggle is as live as an option change: push the plan so enabling the module reaches
+ // the present seam immediately, and disabling it stops the effect on the next present.
+ pushPresentFramePlan(manager);
+ }
}
private static void setConfig(InbuiltModManager manager, UnifiedMod mod, UnifiedMod.ConfigEntry config,
@@ -1429,108 +338,113 @@ private static void setConfig(InbuiltModManager manager, UnifiedMod mod, Unified
setFpsConfig(manager, mod, config.key, value);
return;
}
+ if (org.chimeramc.client.core.mods.render.PresentFrameCatalog
+ .isPresentFrameModule(mod.getId())) {
+ setPresentFrameConfig(manager, mod, config.key, value);
+ return;
+ }
switch (config.key) {
- case CFG_CATALOG_CHAT_MACRO:
- manager.setModConfigString(CFG_CATALOG_CHAT_MACRO, value == null ? "" : value);
+ case ModConfigSchema.CFG_CATALOG_CHAT_MACRO:
+ manager.setModConfigString(ModConfigSchema.CFG_CATALOG_CHAT_MACRO, value == null ? "" : value);
break;
- case CFG_CATALOG_COMMAND_BIND:
- manager.setModConfigString(CFG_CATALOG_COMMAND_BIND, value == null ? "" : value);
+ case ModConfigSchema.CFG_CATALOG_COMMAND_BIND:
+ manager.setModConfigString(ModConfigSchema.CFG_CATALOG_COMMAND_BIND, value == null ? "" : value);
break;
- case CFG_OVERLAY_SIZE:
+ case ModConfigSchema.CFG_OVERLAY_SIZE:
manager.setOverlayButtonSize(mod.getId(), parseInt(value, manager.getOverlayButtonSize(mod.getId())));
break;
- case CFG_OVERLAY_OPACITY:
+ case ModConfigSchema.CFG_OVERLAY_OPACITY:
manager.setOverlayOpacity(mod.getId(), parseInt(value, manager.getOverlayOpacity(mod.getId())));
break;
- case CFG_OVERLAY_LOCK:
+ case ModConfigSchema.CFG_OVERLAY_LOCK:
manager.setOverlayLocked(mod.getId(), parseBoolean(value));
break;
- case CFG_OVERLAY_SHOW_EVERYWHERE:
+ case ModConfigSchema.CFG_OVERLAY_SHOW_EVERYWHERE:
manager.setOverlayShowEverywhere(mod.getId(), parseBoolean(value));
break;
- case CFG_FRAME_GRAPH:
+ case ModConfigSchema.CFG_FRAME_GRAPH:
manager.setFrameTimeGraphEnabled(mod.getId(), parseBoolean(value));
break;
- case CFG_FPS_STATS:
+ case ModConfigSchema.CFG_FPS_STATS:
manager.setFpsStatsEnabled(mod.getId(), parseBoolean(value));
break;
- case CFG_FPS_STATS_WINDOW:
+ case ModConfigSchema.CFG_FPS_STATS_WINDOW:
manager.setFpsStatsWindow(mod.getId(), parseInt(value, manager.getFpsStatsWindow(mod.getId())));
break;
- case CFG_AUTO_SPRINT_KEYBIND:
+ case ModConfigSchema.CFG_AUTO_SPRINT_KEYBIND:
manager.setAutoSprintKeybind(parseInt(value, manager.getAutoSprintKeybind()));
break;
- case CFG_CURSOR_SENSITIVITY:
+ case ModConfigSchema.CFG_CURSOR_SENSITIVITY:
manager.setCursorSensitivity(parseInt(value, manager.getCursorSensitivity()));
break;
- case CFG_ZOOM_LEVEL:
+ case ModConfigSchema.CFG_ZOOM_LEVEL:
manager.setZoomLevel(parseInt(value, manager.getZoomLevel()));
break;
- case CFG_ZOOM_TRANSITION:
+ case ModConfigSchema.CFG_ZOOM_TRANSITION:
manager.setZoomTransitionDuration(parseInt(value, manager.getZoomTransitionDuration()));
break;
- case CFG_ZOOM_KEYBIND:
+ case ModConfigSchema.CFG_ZOOM_KEYBIND:
manager.setZoomKeybind(parseInt(value, manager.getZoomKeybind()));
break;
- case CFG_ZOOM_SCROLL_SENSITIVITY:
+ case ModConfigSchema.CFG_ZOOM_SCROLL_SENSITIVITY:
manager.setZoomScrollSensitivity(parseInt(value, manager.getZoomScrollSensitivity()));
break;
- case CFG_ZOOM_SMOOTHING:
+ case ModConfigSchema.CFG_ZOOM_SMOOTHING:
manager.setZoomSmoothing(parseInt(value, manager.getZoomSmoothing()));
break;
- case CFG_SNAPLOOK_KEYBIND:
+ case ModConfigSchema.CFG_SNAPLOOK_KEYBIND:
manager.setSnaplookKeybind(parseInt(value, manager.getSnaplookKeybind()));
break;
- case CFG_SNAPLOOK_RETURN_SPEED:
+ case ModConfigSchema.CFG_SNAPLOOK_RETURN_SPEED:
manager.setSnaplookReturnSpeed(parseInt(value, manager.getSnaplookReturnSpeed()));
break;
- case CFG_GYRO_SENSITIVITY_X:
+ case ModConfigSchema.CFG_GYRO_SENSITIVITY_X:
manager.setGyroSensitivityX(parseInt(value, manager.getGyroSensitivityX()));
break;
- case CFG_GYRO_SENSITIVITY_Y:
+ case ModConfigSchema.CFG_GYRO_SENSITIVITY_Y:
manager.setGyroSensitivityY(parseInt(value, manager.getGyroSensitivityY()));
break;
- case CFG_GYRO_INVERT_X:
+ case ModConfigSchema.CFG_GYRO_INVERT_X:
manager.setGyroInvertX(parseBoolean(value));
break;
- case CFG_GYRO_INVERT_Y:
+ case ModConfigSchema.CFG_GYRO_INVERT_Y:
manager.setGyroInvertY(parseBoolean(value));
break;
- case CFG_GYRO_DEADZONE:
+ case ModConfigSchema.CFG_GYRO_DEADZONE:
manager.setGyroDeadzone(parseInt(value, manager.getGyroDeadzone()));
break;
- case CFG_HOTBAR_ITEM_ICONS:
+ case ModConfigSchema.CFG_HOTBAR_ITEM_ICONS:
manager.setHotbarItemIconsEnabled(parseBoolean(value));
break;
- case CFG_OFFHAND_MODE:
+ case ModConfigSchema.CFG_OFFHAND_MODE:
manager.setOffhandMode(parseInt(value, manager.getOffhandMode()));
break;
- case CFG_OFFHAND_INVENTORY_KEY:
+ case ModConfigSchema.CFG_OFFHAND_INVENTORY_KEY:
manager.setOffhandInventoryKey(parseInt(value, manager.getOffhandInventoryKey()));
break;
- case CFG_OFFHAND_SWAP_KEY:
+ case ModConfigSchema.CFG_OFFHAND_SWAP_KEY:
// Legacy key kept so a profile saved before the rename still applies.
manager.setOffhandInventoryKey(parseInt(value, manager.getOffhandInventoryKey()));
break;
- case CFG_OFFHAND_USE_KEY:
+ case ModConfigSchema.CFG_OFFHAND_USE_KEY:
manager.setOffhandUseKey(parseInt(value, manager.getOffhandUseKey()));
break;
- case CFG_OFFHAND_KEYBIND:
+ case ModConfigSchema.CFG_OFFHAND_KEYBIND:
manager.setOffhandKeybind(parseInt(value, manager.getOffhandKeybind()));
break;
- case CFG_AIM_SMOOTHING:
+ case ModConfigSchema.CFG_AIM_SMOOTHING:
manager.setAimSmoothing(parseInt(value, manager.getAimSmoothing()));
break;
- case CFG_AIM_SENSITIVITY:
+ case ModConfigSchema.CFG_AIM_SENSITIVITY:
manager.setAimSensitivity(parseInt(value, manager.getAimSensitivity()));
break;
- case CFG_AIM_CROSSHAIR:
+ case ModConfigSchema.CFG_AIM_CROSSHAIR:
manager.setAimCrosshairEnabled(parseBoolean(value));
break;
- case CFG_AIM_CROSSHAIR_STYLE:
+ case ModConfigSchema.CFG_AIM_CROSSHAIR_STYLE:
manager.setAimCrosshairStyle(parseInt(value, manager.getAimCrosshairStyle()));
break;
- case CFG_AIM_CROSSHAIR_COLOR: {
+ case ModConfigSchema.CFG_AIM_CROSSHAIR_COLOR: {
try {
manager.setAimCrosshairColor(Color.parseColor(value));
} catch (Exception ignored) {
@@ -1538,133 +452,133 @@ private static void setConfig(InbuiltModManager manager, UnifiedMod mod, Unified
}
break;
}
- case CFG_AIM_FLASH:
+ case ModConfigSchema.CFG_AIM_FLASH:
manager.setAimFlashEnabled(parseBoolean(value));
break;
- case CFG_ARMOR_HUD_SHOW_TARGET:
+ case ModConfigSchema.CFG_ARMOR_HUD_SHOW_TARGET:
manager.setArmorHudShowTarget(parseBoolean(value));
break;
- case CFG_ARMOR_HUD_SHOW_ENCHANTS:
+ case ModConfigSchema.CFG_ARMOR_HUD_SHOW_ENCHANTS:
manager.setArmorHudShowEnchants(parseBoolean(value));
break;
- case CFG_ARMOR_HUD_STACKED:
+ case ModConfigSchema.CFG_ARMOR_HUD_STACKED:
manager.setArmorHudStacked(parseBoolean(value));
break;
- case CFG_ARMOR_HUD_REFRESH_MS:
+ case ModConfigSchema.CFG_ARMOR_HUD_REFRESH_MS:
manager.setArmorHudRefreshMs(parseInt(value, manager.getArmorHudRefreshMs()));
break;
- case CFG_CRYSTAL_MANUAL_ASSIST:
+ case ModConfigSchema.CFG_CRYSTAL_MANUAL_ASSIST:
manager.setCrystalManualAssist(parseBoolean(value));
break;
- case CFG_CRYSTAL_MIN_SELF_HP:
+ case ModConfigSchema.CFG_CRYSTAL_MIN_SELF_HP:
manager.setCrystalMinSelfHp(parseInt(value, manager.getCrystalMinSelfHp()));
break;
- case CFG_CRYSTAL_MAX_RANGE:
+ case ModConfigSchema.CFG_CRYSTAL_MAX_RANGE:
manager.setCrystalMaxRange(parseInt(value, manager.getCrystalMaxRange()));
break;
- case CFG_CRYSTAL_PLACEMENT_DELAY_MS:
+ case ModConfigSchema.CFG_CRYSTAL_PLACEMENT_DELAY_MS:
manager.setCrystalPlacementDelayMs(parseInt(value, manager.getCrystalPlacementDelayMs()));
break;
- case CFG_CRYSTAL_KEYBIND:
+ case ModConfigSchema.CFG_CRYSTAL_KEYBIND:
manager.setCrystalKeybind(parseInt(value, manager.getCrystalKeybind()));
break;
- case CFG_HITREG_SENSITIVITY:
+ case ModConfigSchema.CFG_HITREG_SENSITIVITY:
manager.setHitRegSensitivity(parseInt(value, manager.getHitRegSensitivity()));
break;
- case CFG_HITREG_SMOOTHING:
+ case ModConfigSchema.CFG_HITREG_SMOOTHING:
manager.setHitRegSmoothing(parseInt(value, manager.getHitRegSmoothing()));
break;
- case CFG_HITREG_PREDICTION:
+ case ModConfigSchema.CFG_HITREG_PREDICTION:
manager.setHitRegPrediction(parseInt(value, manager.getHitRegPrediction()));
break;
- case CFG_HITREG_HAPTIC:
+ case ModConfigSchema.CFG_HITREG_HAPTIC:
manager.setHitRegHapticEnabled(parseBoolean(value));
break;
- case CFG_HIT_TIMING_COOLDOWN_MS:
+ case ModConfigSchema.CFG_HIT_TIMING_COOLDOWN_MS:
manager.setHitTimingCooldownMs(parseInt(value, manager.getHitTimingCooldownMs()));
break;
- case CFG_HIT_TIMING_SHOW_COMBO:
+ case ModConfigSchema.CFG_HIT_TIMING_SHOW_COMBO:
manager.setHitTimingShowCombo(parseBoolean(value));
break;
- case CFG_HIT_TIMING_SHOW_BAR:
+ case ModConfigSchema.CFG_HIT_TIMING_SHOW_BAR:
manager.setHitTimingShowTimingBar(parseBoolean(value));
break;
- case CFG_HITBOX_SHOW_PLAYERS:
+ case ModConfigSchema.CFG_HITBOX_SHOW_PLAYERS:
manager.setHitboxShowPlayers(parseBoolean(value));
break;
- case CFG_HITBOX_SHOW_MOBS:
+ case ModConfigSchema.CFG_HITBOX_SHOW_MOBS:
manager.setHitboxShowMobs(parseBoolean(value));
break;
- case CFG_HITBOX_SHOW_ITEMS:
+ case ModConfigSchema.CFG_HITBOX_SHOW_ITEMS:
manager.setHitboxShowItems(parseBoolean(value));
break;
- case CFG_HITBOX_SHOW_PROJECTILES:
+ case ModConfigSchema.CFG_HITBOX_SHOW_PROJECTILES:
manager.setHitboxShowProjectiles(parseBoolean(value));
org.chimeramc.client.core.mods.inbuilt.overlay.HitboxMod.onConfigChanged(manager);
break;
- case CFG_HITBOX_SHOW_THROWN_ITEMS:
+ case ModConfigSchema.CFG_HITBOX_SHOW_THROWN_ITEMS:
manager.setHitboxShowThrownItems(parseBoolean(value));
org.chimeramc.client.core.mods.inbuilt.overlay.HitboxMod.onConfigChanged(manager);
break;
- case CFG_HITBOX_SHOW_LOOK_LINE:
+ case ModConfigSchema.CFG_HITBOX_SHOW_LOOK_LINE:
manager.setHitboxShowLookLine(parseBoolean(value));
break;
- case CFG_HITBOX_SHOW_CRIT_LINE:
+ case ModConfigSchema.CFG_HITBOX_SHOW_CRIT_LINE:
manager.setHitboxShowCritLine(parseBoolean(value));
break;
- case CFG_HITBOX_SHOW_COMBO_BOX:
+ case ModConfigSchema.CFG_HITBOX_SHOW_COMBO_BOX:
manager.setHitboxShowComboBox(parseBoolean(value));
break;
- case CFG_HITBOX_SHOW_TARGET_BOX:
+ case ModConfigSchema.CFG_HITBOX_SHOW_TARGET_BOX:
manager.setHitboxShowTargetBox(parseBoolean(value));
break;
- case CFG_REACH_POSITION:
+ case ModConfigSchema.CFG_REACH_POSITION:
manager.setReachIndicatorPosition(parseInt(value, manager.getReachIndicatorPosition()));
break;
- case CFG_TRAJECTORY_COLOR:
+ case ModConfigSchema.CFG_TRAJECTORY_COLOR:
try {
manager.setTrajectoryColor(Color.parseColor(value));
} catch (Exception ignored) {
AppLog.warnOnce("CaramelClient", "InbuiltModuleProvider:1620", "Ignored a failure at InbuiltModuleProvider:1620", ignored);
}
break;
- case CFG_HIT_PREDICTION_LOOKAHEAD:
+ case ModConfigSchema.CFG_HIT_PREDICTION_LOOKAHEAD:
manager.setHitPredictionLookAheadMs(parseInt(value, manager.getHitPredictionLookAheadMs()));
break;
- case CFG_KILL_EFFECT_STYLE:
+ case ModConfigSchema.CFG_KILL_EFFECT_STYLE:
manager.setKillEffectStyle(parseInt(value, manager.getKillEffectStyle()));
break;
- case CFG_KILL_EFFECT_COLOR:
+ case ModConfigSchema.CFG_KILL_EFFECT_COLOR:
try {
manager.setKillEffectColor(Color.parseColor(value));
} catch (Exception ignored) {
AppLog.warnOnce("CaramelClient", "InbuiltModuleProvider:1633", "Ignored a failure at InbuiltModuleProvider:1633", ignored);
}
break;
- case CFG_VOICE_MIC:
+ case ModConfigSchema.CFG_VOICE_MIC:
manager.setVoiceMicEnabled(parseBoolean(value));
break;
- case CFG_VOICE_ICON_STYLE:
+ case ModConfigSchema.CFG_VOICE_ICON_STYLE:
manager.setVoiceIconStyle(parseBoolean(value)
? org.chimeramc.client.core.mods.inbuilt.overlay.MicIconStyle.STYLE_SMOOTH
: org.chimeramc.client.core.mods.inbuilt.overlay.MicIconStyle.STYLE_CLASSIC);
break;
- case CFG_VOICE_ICON_ANIMATE:
+ case ModConfigSchema.CFG_VOICE_ICON_ANIMATE:
manager.setVoiceIconAnimated(parseBoolean(value));
break;
- case CFG_VOICE_ICON_NAMETAG:
+ case ModConfigSchema.CFG_VOICE_ICON_NAMETAG:
manager.setVoiceNametagIconEnabled(parseBoolean(value));
break;
// These keys are no longer surfaced in the module dialog (they live on the Voice tab),
// but a config value restored from an older backup can still carry them. They are
// handled so an import cannot silently drop a stored preference.
- case CFG_VOICE_RANGE:
+ case ModConfigSchema.CFG_VOICE_RANGE:
manager.setVoiceRangeBlocks(parseInt(value, (int) manager.getVoiceRangeBlocks()));
break;
- case CFG_VOICE_VOLUME:
+ case ModConfigSchema.CFG_VOICE_VOLUME:
manager.setVoiceVolumePercent(parseInt(value, manager.getVoiceVolumePercent()));
break;
- case CFG_VOICE_CHANNEL: {
+ case ModConfigSchema.CFG_VOICE_CHANNEL: {
String typed = value == null ? "" : value.trim();
String normalized = org.chimeramc.client.core.voice.VoiceChannel.normalize(typed);
boolean privateChannel =
@@ -1728,18 +642,81 @@ private static void setFpsConfig(InbuiltModManager manager, UnifiedMod mod, Stri
}
}
+ /**
+ * Writes a present-frame option, then pushes the whole plan to the native seam.
+ *
+ * The push is what makes an option live: the seam reads the plan on the render thread at the
+ * present boundary, so a changed strength reaches the next presented frame without a restart. A
+ * no-op when the native seam is absent, which is exactly when the modules are badged anyway.
+ */
+ private static void setPresentFrameConfig(InbuiltModManager manager, UnifiedMod mod, String key,
+ String value) {
+ org.chimeramc.client.core.mods.render.PresentFrameCatalog.Module module =
+ org.chimeramc.client.core.mods.render.PresentFrameCatalog.find(mod.getId());
+ if (module == null) return;
+ org.chimeramc.client.core.mods.render.PresentFrameCatalog.Option option = null;
+ for (org.chimeramc.client.core.mods.render.PresentFrameCatalog.Option candidate
+ : module.options) {
+ if (candidate.key.equals(key)) {
+ option = candidate;
+ break;
+ }
+ }
+ if (option == null) return;
+ if (option.isToggle()) {
+ manager.setModConfigBool(key, parseBoolean(value));
+ } else {
+ manager.setModConfigInt(key, parseInt(value, Integer.parseInt(option.defaultValue)));
+ }
+ pushPresentFramePlan(manager);
+ }
+
+ /** Pushes the stored present-frame plan to the native seam (fail-closed when it is absent). */
+ private static void pushPresentFramePlan(InbuiltModManager manager) {
+ try {
+ org.chimeramc.client.core.mods.render.PresentFramePolicy.ValueSource values =
+ new org.chimeramc.client.core.mods.render.PresentFramePolicy.ValueSource() {
+ @Override
+ public int integer(String key, int fallback) {
+ return manager.getModConfigInt(key, fallback);
+ }
+
+ @Override
+ public boolean bool(String key, boolean fallback) {
+ return manager.getModConfigBool(key, fallback);
+ }
+ };
+ org.chimeramc.client.core.mods.render.PresentFramePolicy.Plan plan =
+ org.chimeramc.client.core.mods.render.PresentFramePolicy.plan(
+ org.chimeramc.client.core.backend.HookAvailability.isLive(
+ org.chimeramc.client.core.backend.HookAvailability.Hook.PRESENT),
+ manager.resolveInbuiltModEnabled(ModIds.FRAME_GENERATION, false),
+ manager.resolveInbuiltModEnabled(ModIds.MOTION_BLUR, false),
+ values);
+ org.chimeramc.client.preloader.PreloaderInput.setPresentFramePlan(
+ plan.frameGenerationEnabled, plan.interpolationStrength,
+ plan.maxFramesPerRendered, plan.reduceEdgeArtifacts, plan.motionBlurEnabled,
+ plan.blurStrength, plan.blurBudget, plan.weightByViewSpeed);
+ } catch (Throwable t) {
+ // A missing native seam is expected on a build without the present hook; the modules are
+ // badged in that case, so nothing is silently lost.
+ AppLog.warnOnce("CaramelClient", "InbuiltModuleProvider:pushPresentFramePlan",
+ "Present-frame plan push skipped", t);
+ }
+ }
+
private static boolean setHotbarSlotConfig(InbuiltModManager manager, String key, String value) {
for (int slot = 1; slot <= 9; slot++) {
String overlayKey = ModIds.HOTBAR_SLOT + ":" + slot;
- if (hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_ENABLED).equals(key)) {
+ if (ModConfigSchema.hotbarSlotConfigKey(slot, ModConfigSchema.CFG_HOTBAR_SLOT_ENABLED).equals(key)) {
manager.setHotbarSlotEnabled(slot, parseBoolean(value));
return true;
}
- if (hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_SIZE).equals(key)) {
+ if (ModConfigSchema.hotbarSlotConfigKey(slot, ModConfigSchema.CFG_HOTBAR_SLOT_SIZE).equals(key)) {
manager.setOverlayButtonSize(overlayKey, parseInt(value, manager.getOverlayButtonSize(overlayKey)));
return true;
}
- if (hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_OPACITY).equals(key)) {
+ if (ModConfigSchema.hotbarSlotConfigKey(slot, ModConfigSchema.CFG_HOTBAR_SLOT_OPACITY).equals(key)) {
manager.setOverlayOpacity(overlayKey, parseInt(value, manager.getOverlayOpacity(overlayKey)));
return true;
}
@@ -1755,11 +732,17 @@ private static int parseInt(String value, int fallback) {
}
}
- private static boolean parseBoolean(String value) {
- return "true".equalsIgnoreCase(value) || "1".equals(value);
+ /** Delegates to {@link ModConfigSchema}; kept so the schema mapping stays under test here. */
+ static String schemaTypeFor(UnifiedMod.ConfigType type) {
+ return ModConfigSchema.schemaTypeFor(type);
}
- private static String colorToHex(int color) {
- return String.format(java.util.Locale.US, "#%08X", color);
+ /** Delegates to {@link ModConfigSchema}; kept so the description table stays under test here. */
+ static int configDescriptionRes(String key) {
+ return ModConfigSchema.configDescriptionRes(key);
+ }
+
+ private static boolean parseBoolean(String value) {
+ return "true".equalsIgnoreCase(value) || "1".equals(value);
}
}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/ModConfigSchema.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/ModConfigSchema.java
new file mode 100644
index 00000000..1a8a9a0b
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/ModConfigSchema.java
@@ -0,0 +1,1176 @@
+package org.chimeramc.client.core.mods.inbuilt;
+
+import android.content.Context;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import org.chimeramc.client.R;
+import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
+import org.chimeramc.client.core.mods.inbuilt.model.ModIds;
+import org.chimeramc.client.core.mods.inbuilt.overlay.HitTimingSolver;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * The config layer of the inbuilt modules: the config keys, the {@link UnifiedMod.ConfigEntry} list
+ * for a module, and the {@link RuntimeConfigSchema} a module's dialog renders.
+ *
+ *
Split out of {@code InbuiltModuleProvider} (the file-size ratchet): the provider declares the
+ * modules and applies their settings, while everything about a module's *config shape* lives here.
+ * The keys are package-private so the provider's setter switch and this class cannot drift apart.
+ */
+final class ModConfigSchema {
+
+ private ModConfigSchema() {
+ }
+
+ static final String CFG_OVERLAY_SIZE = "overlay_size";
+ static final String CFG_OVERLAY_OPACITY = "overlay_opacity";
+ static final String CFG_OVERLAY_LOCK = "overlay_lock";
+ static final String CFG_OVERLAY_SHOW_EVERYWHERE = "overlay_show_everywhere";
+ static final String CFG_FRAME_GRAPH = "overlay_frame_graph";
+ static final String CFG_FPS_STATS = "overlay_fps_stats";
+ static final String CFG_FPS_STATS_WINDOW = "overlay_fps_stats_window";
+ static final String CFG_AUTO_SPRINT_KEYBIND = "auto_sprint_keybind";
+ static final String CFG_CURSOR_SENSITIVITY = "cursor_sensitivity";
+ static final String CFG_ZOOM_LEVEL = "zoom_level";
+ static final String CFG_ZOOM_TRANSITION = "zoom_transition";
+ static final String CFG_ZOOM_KEYBIND = "zoom_keybind";
+ static final String CFG_ZOOM_SCROLL_SENSITIVITY = "zoom_scroll_sensitivity";
+ static final String CFG_ZOOM_SMOOTHING = "zoom_smoothing";
+ static final String CFG_SNAPLOOK_KEYBIND = "snaplook_keybind";
+ static final String CFG_SNAPLOOK_RETURN_SPEED = "snaplook_return_speed";
+ static final String CFG_GYRO_SENSITIVITY_X = "gyro_sensitivity_x";
+ static final String CFG_GYRO_SENSITIVITY_Y = "gyro_sensitivity_y";
+ static final String CFG_GYRO_INVERT_X = "gyro_invert_x";
+ static final String CFG_GYRO_INVERT_Y = "gyro_invert_y";
+ static final String CFG_GYRO_DEADZONE = "gyro_deadzone";
+ static final String CFG_HOTBAR_ITEM_ICONS = "hotbar_item_icons";
+ static final String CFG_OFFHAND_MODE = "offhand_mode";
+ static final String CFG_OFFHAND_INVENTORY_KEY = "offhand_inventory_key";
+ static final String CFG_OFFHAND_SWAP_KEY = "offhand_swap_key";
+ static final String CFG_OFFHAND_USE_KEY = "offhand_use_key";
+ static final String CFG_OFFHAND_KEYBIND = "offhand_keybind";
+ static final String CFG_HOTBAR_SLOT_PREFIX = "hotbar_slot_";
+ static final String CFG_HOTBAR_SLOT_ENABLED = "enabled";
+ static final String CFG_HOTBAR_SLOT_SIZE = "size";
+ static final String CFG_HOTBAR_SLOT_OPACITY = "opacity";
+ static final String CFG_AIM_SMOOTHING = "aim_smoothing";
+ static final String CFG_AIM_CROSSHAIR = "aim_crosshair";
+ static final String CFG_AIM_FLASH = "aim_flash";
+ static final String CFG_AIM_SENSITIVITY = "aim_sensitivity";
+ static final String CFG_AIM_CROSSHAIR_STYLE = "aim_crosshair_style";
+ static final String CFG_AIM_CROSSHAIR_COLOR = "aim_crosshair_color";
+ static final String CFG_ARMOR_HUD_SHOW_TARGET = "armor_hud_show_target";
+ static final String CFG_ARMOR_HUD_SHOW_ENCHANTS = "armor_hud_show_enchants";
+ static final String CFG_ARMOR_HUD_STACKED = "armor_hud_stacked";
+ static final String CFG_ARMOR_HUD_REFRESH_MS = "armor_hud_refresh_ms";
+ static final String CFG_CRYSTAL_MIN_SELF_HP = "crystal_min_self_hp";
+ static final String CFG_CRYSTAL_MAX_RANGE = "crystal_max_range";
+ static final String CFG_CRYSTAL_PLACEMENT_DELAY_MS = "crystal_placement_delay_ms";
+ static final String CFG_CRYSTAL_MANUAL_ASSIST = "crystal_manual_assist";
+ static final String CFG_CRYSTAL_KEYBIND = "crystal_keybind";
+ static final String CFG_HITREG_SENSITIVITY = "hitreg_sensitivity";
+ static final String CFG_HITREG_SMOOTHING = "hitreg_smoothing";
+ static final String CFG_HITREG_PREDICTION = "hitreg_prediction";
+ static final String CFG_HITREG_HAPTIC = "hitreg_haptic";
+ static final String CFG_HIT_TIMING_COOLDOWN_MS = "hit_timing_cooldown_ms";
+ static final String CFG_HIT_TIMING_SHOW_COMBO = "hit_timing_show_combo";
+ static final String CFG_HIT_TIMING_SHOW_BAR = "hit_timing_show_bar";
+ static final String CFG_HITBOX_SHOW_PLAYERS = "hitbox_show_players";
+ static final String CFG_HITBOX_SHOW_MOBS = "hitbox_show_mobs";
+ static final String CFG_HITBOX_SHOW_ITEMS = "hitbox_show_items";
+ static final String CFG_HITBOX_SHOW_PROJECTILES = "hitbox_show_projectiles";
+ static final String CFG_HITBOX_SHOW_THROWN_ITEMS = "hitbox_show_thrown_items";
+ static final String CFG_HITBOX_SHOW_LOOK_LINE = "hitbox_show_look_line";
+ static final String CFG_HITBOX_SHOW_CRIT_LINE = "hitbox_show_crit_line";
+ static final String CFG_HITBOX_SHOW_COMBO_BOX = "hitbox_show_combo_box";
+ static final String CFG_HITBOX_SHOW_TARGET_BOX = "hitbox_show_target_box";
+ static final String CFG_VOICE_RANGE = "voice_range_blocks";
+ static final String CFG_VOICE_VOLUME = "voice_volume_percent";
+ static final String CFG_VOICE_CHANNEL = "voice_channel";
+ static final String CFG_VOICE_MIC = "voice_mic_enabled";
+ static final String CFG_VOICE_ICON_STYLE = "voice_icon_style";
+ static final String CFG_VOICE_ICON_ANIMATE = "voice_icon_animate";
+ static final String CFG_VOICE_ICON_NAMETAG = "voice_icon_show_nametag";
+ // PvP Suite (V1.1): overlay-only visual modules, filed under the General tab.
+ static final String CFG_REACH_POSITION = "reach_indicator_position";
+ static final String CFG_TRAJECTORY_COLOR = "trajectory_color";
+ static final String CFG_HIT_PREDICTION_LOOKAHEAD = "hit_prediction_lookahead_ms";
+ static final String CFG_KILL_EFFECT_STYLE = "kill_effect_style";
+ static final String CFG_KILL_EFFECT_COLOR = "kill_effect_color";
+ static final String CFG_CATALOG_CHAT_MACRO = "catalog_chat_macro";
+ static final String CFG_CATALOG_COMMAND_BIND = "catalog_command_bind";
+
+ /** A catalogue module's own options: macro text, or the readout's overlay appearance. */
+ static List createCatalogConfigs(Context context,
+ InbuiltModManager manager,
+ String modId) {
+ List configs = new ArrayList<>();
+ if ("util_chat_macro".equals(modId)) {
+ configs.add(config(CFG_CATALOG_CHAT_MACRO,
+ context.getString(R.string.catalog_macro_text),
+ UnifiedMod.ConfigType.TEXT, "", "", "",
+ manager.getCatalogChatMacro()));
+ } else if ("util_command_bind".equals(modId)) {
+ configs.add(config(CFG_CATALOG_COMMAND_BIND,
+ context.getString(R.string.catalog_command_text),
+ UnifiedMod.ConfigType.TEXT, "", "", "",
+ manager.getCatalogCommandBind()));
+ }
+ if (org.chimeramc.client.core.mods.inbuilt.overlay.CatalogModuleHost
+ .isReadout(modId)) {
+ configs.add(config(CFG_OVERLAY_SIZE,
+ context.getString(R.string.mod_config_overlay_button_size_dp),
+ UnifiedMod.ConfigType.SLIDER_INT, "56", "20", "100",
+ String.valueOf(manager.getOverlayButtonSize(modId))));
+ configs.add(config(CFG_OVERLAY_OPACITY,
+ context.getString(R.string.mod_config_overlay_opacity_percent),
+ UnifiedMod.ConfigType.SLIDER_INT, "100", "0", "100",
+ String.valueOf(manager.getOverlayOpacity(modId))));
+ configs.add(config(CFG_OVERLAY_LOCK,
+ context.getString(R.string.overlay_button_lock),
+ UnifiedMod.ConfigType.TOGGLE, "false", "", "",
+ String.valueOf(manager.isOverlayLocked(modId))));
+ configs.add(config(CFG_OVERLAY_SHOW_EVERYWHERE,
+ context.getString(R.string.mod_config_overlay_show_everywhere),
+ UnifiedMod.ConfigType.TOGGLE, "false", "", "",
+ String.valueOf(manager.isOverlayShowEverywhere(modId))));
+ }
+ return configs;
+ }
+
+ /**
+ * A catalogue module's config dialog: its own content/display options plus a scope note that
+ * says which route the module uses, so a working module does not read as a roadmap stub.
+ */
+ static RuntimeConfigSchema createCatalogConfigSchema(Context context, UnifiedMod mod) {
+ String modId = mod.getId();
+ try {
+ JSONArray categories = new JSONArray();
+ JSONArray nodes = new JSONArray();
+ boolean readout = org.chimeramc.client.core.mods.inbuilt.overlay
+ .CatalogModuleHost.isReadout(modId);
+ categories.put(configCategory(context, "content", R.string.mod_config_category_behavior));
+ if (readout) {
+ categories.put(configCategory(context, "display", R.string.mod_config_category_appearance));
+ }
+ if ("util_chat_macro".equals(modId)) {
+ nodes.put(configNode(context, mod, CFG_CATALOG_CHAT_MACRO, "content"));
+ } else if ("util_command_bind".equals(modId)) {
+ nodes.put(configNode(context, mod, CFG_CATALOG_COMMAND_BIND, "content"));
+ }
+ if (readout) {
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "display"));
+ }
+ int noteRes = catalogScopeNoteRes(modId);
+ if (noteRes != 0) {
+ nodes.put(scopeNoteNode(context, mod, noteRes, "content"));
+ }
+ return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
+ .put("default_category", "content")
+ .put("categories", categories).put("nodes", nodes).toString());
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to build catalogue config schema", e);
+ }
+ }
+
+ private static int catalogScopeNoteRes(String modId) {
+ switch (org.chimeramc.client.core.mods.inbuilt.catalog.PvpModuleScopes.scopeOf(modId)) {
+ case GAME_OPTIONS:
+ return R.string.catalog_options_scope_note;
+ case LOCAL_FEED:
+ case PEER_FEED:
+ return R.string.catalog_local_scope_note;
+ case DEVICE_INPUT:
+ return R.string.catalog_scope_note;
+ default:
+ return 0;
+ }
+ }
+
+ static RuntimeConfigSchema createLocalConfigSchema(Context context, UnifiedMod mod) {
+ String modId = mod.getId();
+ if (ModIds.ARMOR_HUD.equals(modId) || ModIds.CRYSTAL_OPTIMIZER.equals(modId)
+ || ModIds.HIT_REGISTRATION.equals(modId) || ModIds.HIT_TIMING.equals(modId)
+ || ModIds.HITBOX.equals(modId)) {
+ return createCombatConfigSchema(context, mod);
+ }
+ if (ModIds.VOICE_CHAT.equals(modId)) {
+ return createVoiceConfigSchema(context, mod);
+ }
+ if (ModIds.REACH_INDICATOR.equals(modId) || ModIds.TRAJECTORY_PREDICTION.equals(modId)
+ || ModIds.HIT_PREDICTION.equals(modId) || ModIds.KILL_EFFECTS.equals(modId)) {
+ return createPvpSuiteConfigSchema(context, mod);
+ }
+ if (ModIds.OFFHAND.equals(modId)) {
+ return createOffhandConfigSchema(context, mod);
+ }
+ if (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.isFpsModule(modId)) {
+ return createFpsConfigSchema(context, mod);
+ }
+ if (org.chimeramc.client.core.mods.render.PresentFrameCatalog.isPresentFrameModule(modId)) {
+ return createPresentFrameConfigSchema(context, mod);
+ }
+ boolean hotbar = ModIds.HOTBAR_SLOT.equals(mod.getId());
+ if (!hotbar && !ModIds.GYRO.equals(mod.getId())) return null;
+ try {
+ JSONArray categories = new JSONArray();
+ JSONArray nodes = new JSONArray();
+ if (hotbar) {
+ categories.put(configCategory(context, "slots", R.string.mod_config_category_slots));
+ categories.put(configCategory(context, "appearance", R.string.mod_config_category_appearance));
+ categories.put(configCategory(context, "behavior", R.string.mod_config_category_behavior));
+ nodes.put(configNode(context, mod, CFG_HOTBAR_ITEM_ICONS, "slots"));
+ JSONArray slots = new JSONArray();
+ for (int slot = 1; slot <= 9; slot++) {
+ String key = hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_ENABLED);
+ slots.put(new JSONObject().put("key", key).put("value", key)
+ .put("label", mod.findConfigEntry(key).displayName));
+ String section = "slot_" + slot;
+ nodes.put(new JSONObject().put("id", section).put("type", "section")
+ .put("category", "appearance").put("collapsible", true)
+ .put("title", context.getString(R.string.mod_config_hotbar_slot_section, slot)));
+ nodes.put(configNode(context, mod, hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_SIZE), "appearance")
+ .put("section", section));
+ nodes.put(configNode(context, mod, hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_OPACITY), "appearance")
+ .put("section", section));
+ }
+ nodes.put(new JSONObject().put("id", "visible_slots").put("type", "toggle_group")
+ .put("category", "slots").put("title", context.getString(R.string.mod_config_visible_slots))
+ .put("options", slots));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "behavior"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
+ } else {
+ categories.put(configCategory(context, "motion", R.string.mod_config_category_motion));
+ categories.put(configCategory(context, "button", R.string.mod_config_category_button));
+ nodes.put(configNode(context, mod, CFG_GYRO_SENSITIVITY_X, "motion"));
+ nodes.put(configNode(context, mod, CFG_GYRO_SENSITIVITY_Y, "motion"));
+ nodes.put(configNode(context, mod, CFG_GYRO_INVERT_X, "motion"));
+ nodes.put(configNode(context, mod, CFG_GYRO_INVERT_Y, "motion"));
+ nodes.put(configNode(context, mod, CFG_GYRO_DEADZONE, "motion"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "button"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "button"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "button"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "button"));
+ }
+ return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
+ .put("default_category", hotbar ? "slots" : "motion")
+ .put("categories", categories).put("nodes", nodes).toString());
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to build inbuilt config schema", e);
+ }
+ }
+
+ /**
+ * Category layout for the three combat modules. They each get an explicit schema so the
+ * dialog groups related settings rather than listing one flat column.
+ */
+ private static RuntimeConfigSchema createCombatConfigSchema(Context context, UnifiedMod mod) {
+ String modId = mod.getId();
+ try {
+ JSONArray categories = new JSONArray();
+ JSONArray nodes = new JSONArray();
+ String defaultCategory;
+ if (ModIds.ARMOR_HUD.equals(modId)) {
+ categories.put(configCategory(context, "display", R.string.mod_config_category_appearance));
+ categories.put(configCategory(context, "data", R.string.mod_config_category_behavior));
+ defaultCategory = "display";
+ nodes.put(configNode(context, mod, CFG_ARMOR_HUD_SHOW_TARGET, "display"));
+ nodes.put(configNode(context, mod, CFG_ARMOR_HUD_SHOW_ENCHANTS, "display"));
+ nodes.put(configNode(context, mod, CFG_ARMOR_HUD_STACKED, "display"));
+ nodes.put(configNode(context, mod, CFG_ARMOR_HUD_REFRESH_MS, "data"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "data"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "data"));
+ } else if (ModIds.CRYSTAL_OPTIMIZER.equals(modId)) {
+ categories.put(configCategory(context, "safety", R.string.mod_config_category_behavior));
+ categories.put(configCategory(context, "tuning", R.string.mod_config_category_motion));
+ defaultCategory = "safety";
+ nodes.put(configNode(context, mod, CFG_CRYSTAL_MANUAL_ASSIST, "safety"));
+ nodes.put(configNode(context, mod, CFG_CRYSTAL_MIN_SELF_HP, "safety"));
+ nodes.put(configNode(context, mod, CFG_CRYSTAL_MAX_RANGE, "tuning"));
+ nodes.put(configNode(context, mod, CFG_CRYSTAL_PLACEMENT_DELAY_MS, "tuning"));
+ nodes.put(configNode(context, mod, CFG_CRYSTAL_KEYBIND, "tuning"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "tuning"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "tuning"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "safety"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "safety"));
+ } else if (ModIds.HIT_TIMING.equals(modId)) {
+ categories.put(configCategory(context, "timing", R.string.mod_config_category_motion));
+ categories.put(configCategory(context, "display", R.string.mod_config_category_appearance));
+ defaultCategory = "timing";
+ nodes.put(configNode(context, mod, CFG_HIT_TIMING_COOLDOWN_MS, "timing"));
+ nodes.put(configNode(context, mod, CFG_HIT_TIMING_SHOW_COMBO, "display"));
+ nodes.put(configNode(context, mod, CFG_HIT_TIMING_SHOW_BAR, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "display"));
+ } else if (ModIds.HITBOX.equals(modId)) {
+ categories.put(configCategory(context, "entities", R.string.mod_config_category_slots));
+ categories.put(configCategory(context, "guides", R.string.mod_config_category_appearance));
+ defaultCategory = "entities";
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_PLAYERS, "entities"));
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_MOBS, "entities"));
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_ITEMS, "entities"));
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_PROJECTILES, "entities"));
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_THROWN_ITEMS, "entities"));
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_LOOK_LINE, "guides"));
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_TARGET_BOX, "guides"));
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_CRIT_LINE, "guides"));
+ nodes.put(configNode(context, mod, CFG_HITBOX_SHOW_COMBO_BOX, "guides"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "guides"));
+ } else {
+ categories.put(configCategory(context, "aim", R.string.mod_config_category_motion));
+ categories.put(configCategory(context, "feedback", R.string.mod_config_category_button));
+ defaultCategory = "aim";
+ nodes.put(configNode(context, mod, CFG_HITREG_SENSITIVITY, "aim"));
+ nodes.put(configNode(context, mod, CFG_HITREG_SMOOTHING, "aim"));
+ nodes.put(configNode(context, mod, CFG_HITREG_PREDICTION, "aim"));
+ nodes.put(configNode(context, mod, CFG_HITREG_HAPTIC, "feedback"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "feedback"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "feedback"));
+ }
+ int noteRes = scopeNoteRes(modId);
+ if (noteRes != 0) {
+ nodes.put(scopeNoteNode(context, mod, noteRes, defaultCategory));
+ }
+ return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
+ .put("default_category", defaultCategory)
+ .put("categories", categories).put("nodes", nodes).toString());
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to build combat config schema", e);
+ }
+ }
+
+ /**
+ * Category layout for the proximity voice module: the microphone and the icon's appearance.
+ *
+ * Scope is deliberately trimmed to mic-only. Channel name, visibility, capacity and
+ * per-member mute all live on the Voice tab now, so this dialog does not duplicate them -- a
+ * control in two places is a control that can disagree with itself. What remains is global and
+ * genuinely belongs to the module: whether the mic transmits at all, and how the in-world
+ * nametag icon looks and behaves.
+ *
+ *
The scope note is placed in the default category so it is the first thing read -- the
+ * module is named "Proximity Voice Chat" but only applies distance when a position feed
+ * exists, and that distinction has to be on screen, not buried.
+ */
+ private static RuntimeConfigSchema createVoiceConfigSchema(Context context, UnifiedMod mod) {
+ try {
+ JSONArray categories = new JSONArray();
+ JSONArray nodes = new JSONArray();
+ categories.put(configCategory(context, "mic", R.string.mod_config_category_behavior));
+ categories.put(configCategory(context, "icon", R.string.mod_config_category_appearance));
+ categories.put(configCategory(context, "overlay", R.string.mod_config_category_button));
+ nodes.put(configNode(context, mod, CFG_VOICE_MIC, "mic"));
+ nodes.put(configNode(context, mod, CFG_VOICE_ICON_STYLE, "icon"));
+ nodes.put(configNode(context, mod, CFG_VOICE_ICON_ANIMATE, "icon"));
+ nodes.put(configNode(context, mod, CFG_VOICE_ICON_NAMETAG, "icon"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "overlay"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "overlay"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "overlay"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "overlay"));
+ nodes.put(scopeNoteNode(context, mod, R.string.voice_chat_scope_note, "mic"));
+ return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
+ .put("default_category", "mic")
+ .put("categories", categories).put("nodes", nodes).toString());
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to build voice config schema", e);
+ }
+ }
+
+ /**
+ * Category layout for the four PvP Suite modules (V1.1).
+ *
+ *
Each gets a short, focused dialog: the module's own display options plus the shared
+ * "show everywhere" switch, and an info node carrying its scope note. The note is deliberate -
+ * every module in the suite is named after an outcome (Reach Indicator, Hit Prediction) but
+ * only works from the peer feed this build can actually reach, so the dialog says what it can
+ * and cannot see rather than leaving the limit to read as a broken feature.
+ */
+ private static RuntimeConfigSchema createFpsConfigSchema(Context context, UnifiedMod mod) {
+ org.chimeramc.client.core.mods.fps.FpsModuleCatalog.Module module =
+ org.chimeramc.client.core.mods.fps.FpsModuleCatalog.find(mod.getId());
+ if (module == null) return null;
+ try {
+ JSONArray categories = new JSONArray();
+ JSONArray nodes = new JSONArray();
+ // The three FPS modules keep only their implemented options, so a category is only
+ // created when an option actually uses it.
+ categories.put(configCategory(context, "main", R.string.mod_config_category_culling));
+ categories.put(configCategory(context, "batching", R.string.mod_config_category_batching));
+ categories.put(configCategory(context, "memory", R.string.mod_config_category_memory));
+ categories.put(configCategory(context, "timing", R.string.mod_config_category_timing));
+ categories.put(configCategory(context, "network", R.string.mod_config_category_network));
+
+ nodes.put(scopeNoteNode(context, mod, R.string.fps_modules_scope_note, "main"));
+
+ for (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.Option option : module.options) {
+ JSONObject node = new JSONObject()
+ .put("id", option.key)
+ .put("key", option.key)
+ .put("category", fpsCategoryFor(option.key))
+ .put("title", option.label)
+ .put("type", option.isToggle() ? "toggle" : "slider")
+ .put("default_value", option.defaultValue)
+ .put("min_value", String.valueOf(option.min))
+ .put("max_value", String.valueOf(option.max))
+ .put("description", option.description);
+ nodes.put(node);
+ }
+ return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
+ .put("default_category", "main")
+ .put("categories", categories).put("nodes", nodes).toString());
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to build FPS module config schema", e);
+ }
+ }
+
+ /** The category a kept FPS option belongs to. Only the seven implemented options are present. */
+ private static String fpsCategoryFor(String key) {
+ if (key.startsWith("tr_")) {
+ return key.contains("net") || key.contains("coalesce") ? "network" : "timing";
+ }
+ if (key.startsWith("vc_")) {
+ return key.contains("atlas") ? "memory" : "batching";
+ }
+ return "main";
+ }
+
+ /**
+ * The config schema for a present-frame effect module (frame generation, motion blur).
+ *
+ *
Every option is an option {@link org.chimeramc.client.core.mods.render.PresentFramePolicy}
+ * reads, so there is no dead switch. The scope note leads the panel because both modules are
+ * easy to misread as a frame-rate feature; the note says what they actually do.
+ */
+ private static RuntimeConfigSchema createPresentFrameConfigSchema(Context context, UnifiedMod mod) {
+ org.chimeramc.client.core.mods.render.PresentFrameCatalog.Module module =
+ org.chimeramc.client.core.mods.render.PresentFrameCatalog.find(mod.getId());
+ if (module == null) return null;
+ int noteRes = ModIds.FRAME_GENERATION.equals(mod.getId())
+ ? R.string.frame_generation_scope_note
+ : R.string.motion_blur_scope_note;
+ try {
+ JSONArray categories = new JSONArray();
+ JSONArray nodes = new JSONArray();
+ categories.put(configCategory(context, "main", R.string.mod_config_category_effect));
+ categories.put(configCategory(context, "quality", R.string.mod_config_category_memory));
+
+ nodes.put(scopeNoteNode(context, mod, noteRes, "main"));
+
+ for (org.chimeramc.client.core.mods.render.PresentFrameCatalog.Option option
+ : module.options) {
+ JSONObject node = new JSONObject()
+ .put("id", option.key)
+ .put("key", option.key)
+ .put("category", option.key.startsWith("mb_budget") ? "quality" : "main")
+ .put("title", option.label)
+ .put("type", option.isToggle() ? "toggle" : "slider")
+ .put("default_value", option.defaultValue)
+ .put("min_value", String.valueOf(option.min))
+ .put("max_value", String.valueOf(option.max))
+ .put("description", option.description);
+ nodes.put(node);
+ }
+ return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
+ .put("default_category", "main")
+ .put("categories", categories).put("nodes", nodes).toString());
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to build present-frame module config schema", e);
+ }
+ }
+
+ private static RuntimeConfigSchema createPvpSuiteConfigSchema(Context context, UnifiedMod mod) {
+ String modId = mod.getId();
+ try {
+ JSONArray categories = new JSONArray();
+ JSONArray nodes = new JSONArray();
+ categories.put(configCategory(context, "display", R.string.mod_config_category_appearance));
+ categories.put(configCategory(context, "behavior", R.string.mod_config_category_behavior));
+ if (ModIds.REACH_INDICATOR.equals(modId)) {
+ nodes.put(configNode(context, mod, CFG_REACH_POSITION, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
+ } else if (ModIds.TRAJECTORY_PREDICTION.equals(modId)) {
+ nodes.put(configNode(context, mod, CFG_TRAJECTORY_COLOR, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
+ } else if (ModIds.HIT_PREDICTION.equals(modId)) {
+ nodes.put(configNode(context, mod, CFG_HIT_PREDICTION_LOOKAHEAD, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
+ } else {
+ nodes.put(configNode(context, mod, CFG_KILL_EFFECT_STYLE, "display"));
+ nodes.put(configNode(context, mod, CFG_KILL_EFFECT_COLOR, "display"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "behavior"));
+ }
+ int noteRes = scopeNoteRes(modId);
+ if (noteRes != 0) {
+ nodes.put(scopeNoteNode(context, mod, noteRes, "display"));
+ }
+ return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
+ .put("default_category", "display")
+ .put("categories", categories).put("nodes", nodes).toString());
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to build PvP suite config schema", e);
+ }
+ }
+
+ /**
+ * Category layout for the Offhand module.
+ *
+ *
Two categories: what the on-screen button does (mode + overlay look) and the hardware
+ * keybinds. The scope note is in the default category because the module is honest about being
+ * a key-sender — Bedrock resolves the swap/use, so it works everywhere the game does.
+ */
+ private static RuntimeConfigSchema createOffhandConfigSchema(Context context, UnifiedMod mod) {
+ try {
+ JSONArray categories = new JSONArray();
+ JSONArray nodes = new JSONArray();
+ categories.put(configCategory(context, "action", R.string.mod_config_category_behavior));
+ categories.put(configCategory(context, "keys", R.string.mod_config_category_button));
+ categories.put(configCategory(context, "overlay", R.string.mod_config_category_appearance));
+ nodes.put(configNode(context, mod, CFG_OFFHAND_MODE, "action"));
+ nodes.put(configNode(context, mod, CFG_OFFHAND_INVENTORY_KEY, "keys"));
+ nodes.put(configNode(context, mod, CFG_OFFHAND_USE_KEY, "keys"));
+ nodes.put(configNode(context, mod, CFG_OFFHAND_KEYBIND, "keys"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SIZE, "overlay"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_OPACITY, "overlay"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_LOCK, "overlay"));
+ nodes.put(configNode(context, mod, CFG_OVERLAY_SHOW_EVERYWHERE, "overlay"));
+ nodes.put(scopeNoteNode(context, mod, R.string.offhand_scope_note, "action"));
+ return RuntimeConfigSchema.parse(new JSONObject().put("version", 2)
+ .put("default_category", "action")
+ .put("categories", categories).put("nodes", nodes).toString());
+ } catch (JSONException e) {
+ throw new IllegalStateException("Unable to build offhand config schema", e);
+ }
+ }
+
+ private static JSONObject configCategory(Context context, String id, int titleRes) throws JSONException {
+ return new JSONObject().put("id", id).put("title", context.getString(titleRes));
+ }
+
+ private static JSONObject configNode(Context context, UnifiedMod mod, String key, String category) throws JSONException {
+ UnifiedMod.ConfigEntry config = mod.findConfigEntry(key);
+ JSONObject node = new JSONObject().put("id", key).put("key", key)
+ .put("category", category).put("title", config.displayName)
+ .put("type", schemaTypeFor(config.type))
+ .put("default_value", config.defaultValue)
+ .put("min_value", config.minValue).put("max_value", config.maxValue);
+ // A choice node needs its options in the schema: the dialog builds the control from the
+ // node alone, so a RADIO without options renders an empty selector. The labels live in
+ // min_value and the values are their indices, matching the legacy config renderer.
+ if (config.type == UnifiedMod.ConfigType.RADIO) {
+ JSONArray options = new JSONArray();
+ String[] labels = config.minValue != null ? config.minValue.split(",") : new String[0];
+ for (int i = 0; i < labels.length; i++) {
+ options.put(new JSONObject().put("value", String.valueOf(i))
+ .put("label", labels[i].trim()));
+ }
+ node.put("options", options);
+ }
+ int descRes = configDescriptionRes(key);
+ if (descRes != 0) {
+ node.put("description", context.getString(descRes));
+ }
+ if (!config.dependsOn.isEmpty()) {
+ node.put("enabled_when", new JSONArray().put(new JSONObject()
+ .put("key", config.dependsOn).put("op", "truthy")));
+ }
+ // An output the native entity feed would have to supply is disabled rather than hidden:
+ // the switch stays visible so the option is discoverable, but it cannot be turned on to
+ // no effect. The same rule the module-level badge uses, one level down.
+ if (ModIds.outputRequiresGameData(mod.getId(), key)) {
+ node.put("disabled", true);
+ }
+ return node;
+ }
+
+ /**
+ * The `_desc` string for a config key, or 0 when there is none.
+ *
+ *
Resolved through an explicit table rather than a name lookup so a renamed key cannot
+ * silently start matching an unrelated string.
+ */
+ static int configDescriptionRes(String key) {
+ switch (key) {
+ case CFG_ARMOR_HUD_SHOW_TARGET: return R.string.mod_config_armor_hud_show_target_desc;
+ case CFG_ARMOR_HUD_SHOW_ENCHANTS: return R.string.mod_config_armor_hud_show_enchants_desc;
+ case CFG_ARMOR_HUD_STACKED: return R.string.mod_config_armor_hud_stacked_desc;
+ case CFG_ARMOR_HUD_REFRESH_MS: return R.string.mod_config_armor_hud_refresh_ms_desc;
+ case CFG_HITREG_SENSITIVITY: return R.string.mod_config_hitreg_sensitivity_desc;
+ case CFG_HITREG_SMOOTHING: return R.string.mod_config_hitreg_smoothing_desc;
+ case CFG_HITREG_PREDICTION: return R.string.mod_config_hitreg_prediction_desc;
+ case CFG_HITREG_HAPTIC: return R.string.mod_config_hitreg_haptic_desc;
+ case CFG_CRYSTAL_PLACEMENT_DELAY_MS: return R.string.mod_config_crystal_placement_delay_ms_desc;
+ case CFG_CRYSTAL_MANUAL_ASSIST: return R.string.mod_config_crystal_manual_assist_desc;
+ case CFG_CRYSTAL_MIN_SELF_HP: return R.string.mod_config_crystal_min_self_hp_desc;
+ case CFG_CRYSTAL_MAX_RANGE: return R.string.mod_config_crystal_max_range_desc;
+ case CFG_CRYSTAL_KEYBIND: return R.string.mod_config_crystal_keybind_desc;
+ case CFG_HIT_TIMING_COOLDOWN_MS: return R.string.mod_config_hit_timing_cooldown_ms_desc;
+ case CFG_HIT_TIMING_SHOW_COMBO: return R.string.mod_config_hit_timing_show_combo_desc;
+ case CFG_HIT_TIMING_SHOW_BAR: return R.string.mod_config_hit_timing_show_bar_desc;
+ case CFG_HITBOX_SHOW_PLAYERS: return R.string.mod_config_hitbox_show_players_desc;
+ case CFG_HITBOX_SHOW_MOBS: return R.string.mod_config_hitbox_show_mobs_desc;
+ case CFG_HITBOX_SHOW_ITEMS: return R.string.mod_config_hitbox_show_items_desc;
+ case CFG_HITBOX_SHOW_PROJECTILES: return R.string.mod_config_hitbox_show_projectiles_desc;
+ case CFG_HITBOX_SHOW_THROWN_ITEMS: return R.string.mod_config_hitbox_show_thrown_items_desc;
+ case CFG_HITBOX_SHOW_LOOK_LINE: return R.string.mod_config_hitbox_show_look_line_desc;
+ case CFG_HITBOX_SHOW_CRIT_LINE: return R.string.mod_config_hitbox_show_crit_line_desc;
+ case CFG_HITBOX_SHOW_COMBO_BOX: return R.string.mod_config_hitbox_show_combo_box_desc;
+ case CFG_HITBOX_SHOW_TARGET_BOX: return R.string.mod_config_hitbox_show_target_box_desc;
+ case CFG_VOICE_RANGE: return R.string.mod_config_voice_range_desc;
+ case CFG_VOICE_VOLUME: return R.string.mod_config_voice_volume_desc;
+ case CFG_VOICE_CHANNEL: return R.string.mod_config_voice_channel_desc;
+ case CFG_VOICE_MIC: return R.string.mod_config_voice_mic_desc;
+ case CFG_VOICE_ICON_STYLE: return R.string.mod_config_voice_icon_style_desc;
+ case CFG_VOICE_ICON_ANIMATE: return R.string.mod_config_voice_icon_animate_desc;
+ case CFG_VOICE_ICON_NAMETAG: return R.string.mod_config_voice_icon_nametag_desc;
+ case CFG_REACH_POSITION: return R.string.mod_config_reach_position_desc;
+ case CFG_TRAJECTORY_COLOR: return R.string.mod_config_trajectory_color_desc;
+ case CFG_HIT_PREDICTION_LOOKAHEAD: return R.string.mod_config_hit_prediction_lookahead_desc;
+ case CFG_KILL_EFFECT_STYLE: return R.string.mod_config_kill_effect_style_desc;
+ case CFG_KILL_EFFECT_COLOR: return R.string.mod_config_kill_effect_color_desc;
+ default: return 0;
+ }
+ }
+
+ /**
+ * An informational node carrying the module's scope note, or null when it has none.
+ *
+ *
These modules are named after an outcome ("Hit Registration") but deliberately stop
+ * short of it — the server decides whether a hit lands, and the box feed may not exist yet.
+ * Saying so in the dialog is what stops the honest limit reading as a broken feature.
+ */
+ private static JSONObject scopeNoteNode(Context context, UnifiedMod mod,
+ int noteRes, String category) throws JSONException {
+ return new JSONObject()
+ .put("id", mod.getId() + "_scope_note")
+ .put("type", "info")
+ .put("category", category)
+ .put("title", context.getString(R.string.mod_config_scope_note_title))
+ .put("description", context.getString(noteRes));
+ }
+
+ private static int scopeNoteRes(String modId) {
+ if (ModIds.HIT_REGISTRATION.equals(modId)) return R.string.hitreg_scope_note;
+ if (ModIds.CRYSTAL_OPTIMIZER.equals(modId)) return R.string.crystal_optimizer_fairness_note;
+ if (ModIds.HIT_TIMING.equals(modId)) return R.string.hit_timing_scope_note;
+ if (ModIds.HITBOX.equals(modId)) return R.string.hitbox_scope_note;
+ if (ModIds.ARMOR_HUD.equals(modId)) return R.string.armor_hud_no_data;
+ if (ModIds.VOICE_CHAT.equals(modId)) return R.string.voice_chat_scope_note;
+ if (ModIds.REACH_INDICATOR.equals(modId)) return R.string.reach_indicator_scope_note;
+ if (ModIds.TRAJECTORY_PREDICTION.equals(modId)) return R.string.trajectory_prediction_scope_note;
+ if (ModIds.HIT_PREDICTION.equals(modId)) return R.string.hit_prediction_scope_note;
+ if (ModIds.KILL_EFFECTS.equals(modId)) return R.string.kill_effects_scope_note;
+ if (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.isFpsModule(modId)) {
+ return R.string.fps_modules_scope_note;
+ }
+ return 0;
+ }
+
+ /**
+ * Maps a config entry's type to the schema node type the dialog renders.
+ *
+ * This must be the real type, not a toggle/slider default: the dialog builds its control
+ * from the node type alone, so a KEYBIND node declared as a slider would show a 0..100
+ * range and write a small integer over the stored key code, and a RADIO node would lose
+ * its options. Anything the schema cannot express (the legacy colour picker) still falls
+ * back to a slider rather than silently dropping the setting.
+ */
+ static String schemaTypeFor(UnifiedMod.ConfigType type) {
+ switch (type) {
+ case TOGGLE:
+ return "toggle";
+ case SLIDER_INT:
+ return "slider_int";
+ case SLIDER_FLOAT:
+ return "slider_float";
+ case RADIO:
+ return "choice";
+ case KEYBIND:
+ return "keybind";
+ case TEXT:
+ return "text";
+ case BUTTON:
+ return "button";
+ case COLOR:
+ default:
+ return "slider_int";
+ }
+ }
+
+ static List createConfigs(Context context,
+ InbuiltModManager manager,
+ String modId) {
+ List configs = new ArrayList<>();
+ if (ModIds.POJAV_CONTROLS.equals(modId) || ModIds.MORE_BUTTONS.equals(modId)) return configs;
+ if (!ModIds.CHICK_PET.equals(modId)) {
+ if (!ModIds.HOTBAR_SLOT.equals(modId)) {
+ configs.add(config(CFG_OVERLAY_SIZE,
+ context.getString(R.string.mod_config_overlay_button_size_dp),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "56", "20", "100",
+ String.valueOf(manager.getOverlayButtonSize(modId))));
+ configs.add(config(CFG_OVERLAY_OPACITY,
+ context.getString(R.string.mod_config_overlay_opacity_percent),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "100", "0", "100",
+ String.valueOf(manager.getOverlayOpacity(modId))));
+ }
+ configs.add(config(CFG_OVERLAY_LOCK,
+ context.getString(R.string.overlay_button_lock),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isOverlayLocked(modId))));
+ configs.add(config(CFG_OVERLAY_SHOW_EVERYWHERE,
+ context.getString(R.string.mod_config_overlay_show_everywhere),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isOverlayShowEverywhere(modId))));
+ }
+
+ // The frame-time graph is an FPS-overlay-only option: it needs the FPS meter,
+ // which only that overlay runs.
+ if (ModIds.FPS_DISPLAY.equals(modId)) {
+ configs.add(config(CFG_FRAME_GRAPH,
+ context.getString(R.string.mod_config_fps_frame_graph),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isFrameTimeGraphEnabled(modId))));
+ configs.add(config(CFG_FPS_STATS,
+ context.getString(R.string.mod_config_fps_stats),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isFpsStatsEnabled(modId))));
+ configs.add(config(CFG_FPS_STATS_WINDOW,
+ context.getString(R.string.mod_config_fps_stats_window),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ String.valueOf(InbuiltModManager.FPS_STATS_WINDOW_DEFAULT),
+ String.valueOf(InbuiltModManager.FPS_STATS_WINDOW_MIN),
+ String.valueOf(InbuiltModManager.FPS_STATS_WINDOW_MAX),
+ String.valueOf(manager.getFpsStatsWindow(modId))));
+ }
+
+ if (ModIds.HOTBAR_SLOT.equals(modId)) {
+ configs.add(config(CFG_HOTBAR_ITEM_ICONS,
+ context.getString(R.string.mod_config_hotbar_item_icons),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isHotbarItemIconsEnabled())));
+ for (int slot = 1; slot <= 9; slot++) {
+ String enabledKey = hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_ENABLED);
+ String overlayKey = ModIds.HOTBAR_SLOT + ":" + slot;
+ configs.add(config(enabledKey,
+ context.getString(R.string.mod_config_hotbar_slot_enabled, slot),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHotbarSlotEnabled(slot))));
+ configs.add(config(hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_SIZE),
+ context.getString(R.string.mod_config_hotbar_slot_size, slot),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "56", "20", "100",
+ String.valueOf(manager.getOverlayButtonSize(overlayKey)), enabledKey));
+ configs.add(config(hotbarSlotConfigKey(slot, CFG_HOTBAR_SLOT_OPACITY),
+ context.getString(R.string.mod_config_hotbar_slot_opacity, slot),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "100", "0", "100",
+ String.valueOf(manager.getOverlayOpacity(overlayKey)), enabledKey));
+ }
+ }
+
+ if (ModIds.OFFHAND.equals(modId)) {
+ configs.add(config(CFG_OFFHAND_MODE,
+ context.getString(R.string.mod_config_offhand_mode),
+ UnifiedMod.ConfigType.RADIO,
+ context.getString(R.string.mod_config_offhand_mode_labels), "", "",
+ String.valueOf(manager.getOffhandMode())));
+ configs.add(config(CFG_OFFHAND_INVENTORY_KEY,
+ context.getString(R.string.mod_config_offhand_inventory_key),
+ UnifiedMod.ConfigType.KEYBIND,
+ "", "", "",
+ String.valueOf(manager.getOffhandInventoryKey())));
+ configs.add(config(CFG_OFFHAND_USE_KEY,
+ context.getString(R.string.mod_config_offhand_use_key),
+ UnifiedMod.ConfigType.KEYBIND,
+ "", "", "",
+ String.valueOf(manager.getOffhandUseKey())));
+ configs.add(config(CFG_OFFHAND_KEYBIND,
+ context.getString(R.string.mod_config_offhand_keybind),
+ UnifiedMod.ConfigType.KEYBIND,
+ "", "", "",
+ String.valueOf(manager.getOffhandKeybind())));
+ }
+
+ if (ModIds.AUTO_SPRINT.equals(modId)) {
+ configs.add(config(CFG_AUTO_SPRINT_KEYBIND,
+ context.getString(R.string.mod_config_auto_sprint_keybind),
+ UnifiedMod.ConfigType.KEYBIND,
+ "", "", "",
+ String.valueOf(manager.getAutoSprintKeybind())));
+ } else if (ModIds.VIRTUAL_CURSOR.equals(modId)) {
+ configs.add(config(CFG_CURSOR_SENSITIVITY,
+ context.getString(R.string.mod_config_cursor_sensitivity_percent),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "120", "10", "200",
+ String.valueOf(manager.getCursorSensitivity())));
+ } else if (ModIds.ZOOM.equals(modId)) {
+ // The keybind is always offered: it is the input that would drive zoom once a backend
+ // provides one. The four shaping sliders are hidden while no backend advertises
+ // Feature.ZOOM, because they change a field-of-view zoom nothing can apply — offering
+ // them would be four controls that visibly do nothing.
+ configs.add(config(CFG_ZOOM_KEYBIND,
+ context.getString(R.string.mod_config_zoom_keybind),
+ UnifiedMod.ConfigType.KEYBIND,
+ "", "", "",
+ String.valueOf(manager.getZoomKeybind())));
+ if (ModIds.requiresBackendFeature(ModIds.ZOOM)) {
+ return configs;
+ }
+ configs.add(config(CFG_ZOOM_LEVEL,
+ context.getString(R.string.mod_config_zoom_level_percent),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "10", "-20", "100",
+ String.valueOf(manager.getZoomLevel())));
+ configs.add(config(CFG_ZOOM_TRANSITION,
+ context.getString(R.string.mod_config_zoom_transition),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "150", "0", "1000",
+ String.valueOf(manager.getZoomTransitionDuration())));
+ configs.add(config(CFG_ZOOM_SCROLL_SENSITIVITY,
+ context.getString(R.string.mod_config_zoom_scroll_sensitivity),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ String.valueOf(100),
+ String.valueOf(InbuiltModManager.MIN_ZOOM_SCROLL_SENSITIVITY),
+ String.valueOf(InbuiltModManager.MAX_ZOOM_SCROLL_SENSITIVITY),
+ String.valueOf(manager.getZoomScrollSensitivity())));
+ configs.add(config(CFG_ZOOM_SMOOTHING,
+ context.getString(R.string.mod_config_zoom_smoothing),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "0", "0",
+ String.valueOf(InbuiltModManager.MAX_ZOOM_SMOOTHING),
+ String.valueOf(manager.getZoomSmoothing())));
+ } else if (ModIds.SNAPLOOK.equals(modId)) {
+ configs.add(config(CFG_SNAPLOOK_KEYBIND,
+ context.getString(R.string.mod_config_snaplook_keybind),
+ UnifiedMod.ConfigType.KEYBIND,
+ "", "", "",
+ String.valueOf(manager.getSnaplookKeybind())));
+ configs.add(config(CFG_SNAPLOOK_RETURN_SPEED,
+ context.getString(R.string.mod_config_snaplook_return_speed),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "50", "0",
+ String.valueOf(InbuiltModManager.MAX_SNAPLOOK_RETURN_SPEED),
+ String.valueOf(manager.getSnaplookReturnSpeed())));
+ } else if (ModIds.GYRO.equals(modId)) {
+ configs.add(config(CFG_GYRO_SENSITIVITY_X,
+ context.getString(R.string.mod_config_gyro_sensitivity_x),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "100", "10", "300",
+ String.valueOf(manager.getGyroSensitivityX())));
+ configs.add(config(CFG_GYRO_SENSITIVITY_Y,
+ context.getString(R.string.mod_config_gyro_sensitivity_y),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "100", "10", "300",
+ String.valueOf(manager.getGyroSensitivityY())));
+ configs.add(config(CFG_GYRO_INVERT_X,
+ context.getString(R.string.mod_config_gyro_invert_x),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isGyroInvertX())));
+ configs.add(config(CFG_GYRO_INVERT_Y,
+ context.getString(R.string.mod_config_gyro_invert_y),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isGyroInvertY())));
+ configs.add(config(CFG_GYRO_DEADZONE,
+ context.getString(R.string.mod_config_gyro_deadzone),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "5", "0", "50",
+ String.valueOf(manager.getGyroDeadzone())));
+ } else if (ModIds.AIM_SETTINGS.equals(modId)) {
+ configs.add(config(CFG_AIM_SENSITIVITY,
+ context.getString(R.string.mod_config_aim_sensitivity),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "100", "10", "300",
+ String.valueOf(manager.getAimSensitivity())));
+ configs.add(config(CFG_AIM_SMOOTHING,
+ context.getString(R.string.mod_config_aim_smoothing),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "40", "0", "95",
+ String.valueOf(manager.getAimSmoothing())));
+ configs.add(config(CFG_AIM_CROSSHAIR,
+ context.getString(R.string.mod_config_aim_crosshair),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isAimCrosshairEnabled())));
+ configs.add(config(CFG_AIM_CROSSHAIR_STYLE,
+ context.getString(R.string.aim_crosshair_style),
+ UnifiedMod.ConfigType.RADIO,
+ "1",
+ context.getString(R.string.aim_crosshair_style_dot) + ","
+ + context.getString(R.string.aim_crosshair_style_cross) + ","
+ + context.getString(R.string.aim_crosshair_style_circle),
+ "",
+ String.valueOf(manager.getAimCrosshairStyle())));
+ configs.add(config(CFG_AIM_CROSSHAIR_COLOR,
+ context.getString(R.string.aim_crosshair_color),
+ UnifiedMod.ConfigType.COLOR,
+ "#FF3DDC84", "", "",
+ colorToHex(manager.getAimCrosshairColor())));
+ configs.add(config(CFG_AIM_FLASH,
+ context.getString(R.string.mod_config_aim_flash),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isAimFlashEnabled())));
+ } else if (ModIds.ARMOR_HUD.equals(modId)) {
+ configs.add(config(CFG_ARMOR_HUD_SHOW_TARGET,
+ context.getString(R.string.mod_config_armor_hud_show_target),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isArmorHudShowTarget())));
+ configs.add(config(CFG_ARMOR_HUD_SHOW_ENCHANTS,
+ context.getString(R.string.mod_config_armor_hud_show_enchants),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.isArmorHudShowEnchants())));
+ configs.add(config(CFG_ARMOR_HUD_STACKED,
+ context.getString(R.string.mod_config_armor_hud_stacked),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isArmorHudStacked())));
+ configs.add(config(CFG_ARMOR_HUD_REFRESH_MS,
+ context.getString(R.string.mod_config_armor_hud_refresh_ms),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "100", "50", "1000",
+ String.valueOf(manager.getArmorHudRefreshMs())));
+ } else if (ModIds.CRYSTAL_OPTIMIZER.equals(modId)) {
+ // Manual assist is listed first and defaults on: it is the variant that does not
+ // automate a player action, so it must be the one a fresh profile lands on.
+ configs.add(config(CFG_CRYSTAL_MANUAL_ASSIST,
+ context.getString(R.string.mod_config_crystal_manual_assist),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isCrystalManualAssist())));
+ configs.add(config(CFG_CRYSTAL_MIN_SELF_HP,
+ context.getString(R.string.mod_config_crystal_min_self_hp),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "14", "1", "20",
+ String.valueOf(manager.getCrystalMinSelfHp())));
+ configs.add(config(CFG_CRYSTAL_MAX_RANGE,
+ context.getString(R.string.mod_config_crystal_max_range),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "4", "1", "8",
+ String.valueOf(manager.getCrystalMaxRange())));
+ configs.add(config(CFG_CRYSTAL_PLACEMENT_DELAY_MS,
+ context.getString(R.string.mod_config_crystal_placement_delay_ms),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "50", "0", "1000",
+ String.valueOf(manager.getCrystalPlacementDelayMs())));
+ configs.add(config(CFG_CRYSTAL_KEYBIND,
+ context.getString(R.string.mod_config_crystal_keybind),
+ UnifiedMod.ConfigType.KEYBIND,
+ "", "", "",
+ String.valueOf(manager.getCrystalKeybind())));
+ } else if (ModIds.HIT_REGISTRATION.equals(modId)) {
+ configs.add(config(CFG_HITREG_SENSITIVITY,
+ context.getString(R.string.mod_config_hitreg_sensitivity),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "100", "10", "300",
+ String.valueOf(manager.getHitRegSensitivity())));
+ configs.add(config(CFG_HITREG_SMOOTHING,
+ context.getString(R.string.mod_config_hitreg_smoothing),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "25", "0", "95",
+ String.valueOf(manager.getHitRegSmoothing())));
+ configs.add(config(CFG_HITREG_PREDICTION,
+ context.getString(R.string.mod_config_hitreg_prediction),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ "35", "0", "100",
+ String.valueOf(manager.getHitRegPrediction())));
+ configs.add(config(CFG_HITREG_HAPTIC,
+ context.getString(R.string.mod_config_hitreg_haptic),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitRegHapticEnabled())));
+ } else if (ModIds.HIT_TIMING.equals(modId)) {
+ configs.add(config(CFG_HIT_TIMING_COOLDOWN_MS,
+ context.getString(R.string.mod_config_hit_timing_cooldown_ms),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ String.valueOf(HitTimingSolver.DEFAULT_COOLDOWN_MS), "50", "2000",
+ String.valueOf(manager.getHitTimingCooldownMs())));
+ configs.add(config(CFG_HIT_TIMING_SHOW_COMBO,
+ context.getString(R.string.mod_config_hit_timing_show_combo),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitTimingShowCombo())));
+ configs.add(config(CFG_HIT_TIMING_SHOW_BAR,
+ context.getString(R.string.mod_config_hit_timing_show_bar),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitTimingShowTimingBar())));
+ } else if (ModIds.HITBOX.equals(modId)) {
+ configs.add(config(CFG_HITBOX_SHOW_PLAYERS,
+ context.getString(R.string.mod_config_hitbox_show_players),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowPlayers())));
+ configs.add(config(CFG_HITBOX_SHOW_MOBS,
+ context.getString(R.string.mod_config_hitbox_show_mobs),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowMobs())));
+ configs.add(config(CFG_HITBOX_SHOW_ITEMS,
+ context.getString(R.string.mod_config_hitbox_show_items),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowItems())));
+ configs.add(config(CFG_HITBOX_SHOW_PROJECTILES,
+ context.getString(R.string.mod_config_hitbox_show_projectiles),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowProjectiles())));
+ configs.add(config(CFG_HITBOX_SHOW_THROWN_ITEMS,
+ context.getString(R.string.mod_config_hitbox_show_thrown_items),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowThrownItems())));
+ configs.add(config(CFG_HITBOX_SHOW_LOOK_LINE,
+ context.getString(R.string.mod_config_hitbox_show_look_line),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowLookLine())));
+ configs.add(config(CFG_HITBOX_SHOW_CRIT_LINE,
+ context.getString(R.string.mod_config_hitbox_show_crit_line),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowCritLine())));
+ configs.add(config(CFG_HITBOX_SHOW_COMBO_BOX,
+ context.getString(R.string.mod_config_hitbox_show_combo_box),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowComboBox())));
+ configs.add(config(CFG_HITBOX_SHOW_TARGET_BOX,
+ context.getString(R.string.mod_config_hitbox_show_target_box),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isHitboxShowTargetBox())));
+ } else if (ModIds.REACH_INDICATOR.equals(modId)) {
+ configs.add(config(CFG_REACH_POSITION,
+ context.getString(R.string.mod_config_reach_position),
+ UnifiedMod.ConfigType.RADIO,
+ "0",
+ context.getString(R.string.mod_config_reach_position_below) + ","
+ + context.getString(R.string.mod_config_reach_position_hotbar),
+ "",
+ String.valueOf(manager.getReachIndicatorPosition())));
+ } else if (ModIds.TRAJECTORY_PREDICTION.equals(modId)) {
+ configs.add(config(CFG_TRAJECTORY_COLOR,
+ context.getString(R.string.mod_config_trajectory_color),
+ UnifiedMod.ConfigType.COLOR,
+ "#FF7CFC5A", "", "",
+ colorToHex(manager.getTrajectoryColor())));
+ } else if (ModIds.HIT_PREDICTION.equals(modId)) {
+ configs.add(config(CFG_HIT_PREDICTION_LOOKAHEAD,
+ context.getString(R.string.mod_config_hit_prediction_lookahead),
+ UnifiedMod.ConfigType.SLIDER_INT,
+ String.valueOf(org.chimeramc.client.core.mods.inbuilt.overlay.HitPredictor.DEFAULT_LOOK_AHEAD_MS),
+ String.valueOf(org.chimeramc.client.core.mods.inbuilt.overlay.HitPredictor.MIN_LOOK_AHEAD_MS),
+ String.valueOf(org.chimeramc.client.core.mods.inbuilt.overlay.HitPredictor.MAX_LOOK_AHEAD_MS),
+ String.valueOf(manager.getHitPredictionLookAheadMs())));
+ } else if (ModIds.KILL_EFFECTS.equals(modId)) {
+ configs.add(config(CFG_KILL_EFFECT_STYLE,
+ context.getString(R.string.mod_config_kill_effect_style),
+ UnifiedMod.ConfigType.RADIO,
+ "0",
+ context.getString(R.string.kill_effect_style_burst) + ","
+ + context.getString(R.string.kill_effect_style_column) + ","
+ + context.getString(R.string.kill_effect_style_ring),
+ "",
+ String.valueOf(manager.getKillEffectStyle())));
+ configs.add(config(CFG_KILL_EFFECT_COLOR,
+ context.getString(R.string.mod_config_kill_effect_color),
+ UnifiedMod.ConfigType.COLOR,
+ "#FFFFC24B", "", "",
+ colorToHex(manager.getKillEffectColor())));
+ } else if (ModIds.VOICE_CHAT.equals(modId)) {
+ // Trimmed to mic-only scope: the mic master switch plus the in-world icon's look and
+ // behaviour. Channel, visibility, capacity and per-member mute are owned by the Voice
+ // tab, so they are deliberately absent here to avoid a second source of truth.
+ configs.add(config(CFG_VOICE_MIC,
+ context.getString(R.string.mod_config_voice_mic),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isVoiceMicEnabled())));
+ configs.add(config(CFG_VOICE_ICON_STYLE,
+ context.getString(R.string.mod_config_voice_icon_style),
+ UnifiedMod.ConfigType.TOGGLE,
+ "false", "", "",
+ String.valueOf(manager.getVoiceIconStyle()
+ == org.chimeramc.client.core.mods.inbuilt.overlay.MicIconStyle.STYLE_SMOOTH)));
+ configs.add(config(CFG_VOICE_ICON_ANIMATE,
+ context.getString(R.string.mod_config_voice_icon_animate),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isVoiceIconAnimated())));
+ configs.add(config(CFG_VOICE_ICON_NAMETAG,
+ context.getString(R.string.mod_config_voice_icon_nametag),
+ UnifiedMod.ConfigType.TOGGLE,
+ "true", "", "",
+ String.valueOf(manager.isVoiceNametagIconEnabled())));
+ }
+ if (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.isFpsModule(modId)) {
+ // The FPS options come straight from the catalogue, so the dialog, the defaults and the
+ // stored values read one source. A missing stored value reads the catalogue default.
+ org.chimeramc.client.core.mods.fps.FpsModuleCatalog.Module module =
+ org.chimeramc.client.core.mods.fps.FpsModuleCatalog.find(modId);
+ for (org.chimeramc.client.core.mods.fps.FpsModuleCatalog.Option option : module.options) {
+ if (option.isToggle()) {
+ boolean current = manager.getModConfigBool(option.key,
+ Boolean.parseBoolean(option.defaultValue));
+ configs.add(config(option.key, option.label, UnifiedMod.ConfigType.TOGGLE,
+ option.defaultValue, "", "", String.valueOf(current)));
+ } else {
+ int def = Integer.parseInt(option.defaultValue);
+ int current = manager.getModConfigInt(option.key, def);
+ configs.add(config(option.key, option.label, UnifiedMod.ConfigType.SLIDER_INT,
+ option.defaultValue, String.valueOf(option.min),
+ String.valueOf(option.max), String.valueOf(current)));
+ }
+ }
+ }
+ return configs;
+ }
+
+ private static UnifiedMod.ConfigEntry config(String key, String displayName,
+ UnifiedMod.ConfigType type,
+ String defaultValue, String minValue,
+ String maxValue, String currentValue) {
+ return config(key, displayName, type, defaultValue, minValue, maxValue, currentValue, "");
+ }
+
+ private static UnifiedMod.ConfigEntry config(String key, String displayName,
+ UnifiedMod.ConfigType type,
+ String defaultValue, String minValue,
+ String maxValue, String currentValue,
+ String dependsOn) {
+ return new UnifiedMod.ConfigEntry(
+ key, displayName, type, defaultValue, minValue, maxValue,
+ currentValue, dependsOn
+ );
+ }
+
+ static String hotbarSlotConfigKey(int slot, String setting) {
+ return CFG_HOTBAR_SLOT_PREFIX + slot + "_" + setting;
+ }
+
+ private static String colorToHex(int color) {
+ return String.format(java.util.Locale.US, "#%08X", color);
+ }
+}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/RuntimeConfigControl.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/RuntimeConfigControl.java
deleted file mode 100644
index 5a92780a..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/RuntimeConfigControl.java
+++ /dev/null
@@ -1,155 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt;
-
-import java.util.List;
-
-/**
- * The control a schema node turns into, derived as pure rules.
- *
- * {@link RuntimeConfigView} already builds a control per node, but the parameters that decide
- * what it builds were inline literals spread through the view: the 0..100 int default, the
- * 0..1 float default, the step of 1, the clamp, and the "four or fewer options means segmented"
- * choice rule. A rule inside a View cannot be unit-tested, and the failure it produces is quiet -
- * a slider whose bounds disagree with the schema writes a value the mod then clamps or ignores.
- *
- *
This holds those rules. The view still owns the widgets; it asks this what to build.
- */
-public final class RuntimeConfigControl {
-
- /** An integer slider's usable bounds and current value. */
- public static final class IntRange {
- public final int min;
- public final int max;
- public final int step;
- public final int value;
-
- IntRange(int min, int max, int step, int value) {
- this.min = min;
- this.max = max;
- this.step = step;
- this.value = value;
- }
-
- /** How many steps the track has, at least 1 so a degenerate range still renders. */
- public int steps() {
- int span = max - min;
- if (span <= 0) return 1;
- return Math.max(1, (span + step - 1) / step);
- }
-
- /** The value for a track position, clamped to the range. */
- public int valueAt(int position) {
- int clamped = Math.max(0, Math.min(steps(), position));
- return Math.min(max, min + clamped * step);
- }
-
- /** The track position for a value, clamped to the range. */
- public int positionOf(int v) {
- int clamped = Math.max(min, Math.min(max, v));
- return Math.min(steps(), Math.round((clamped - min) / (float) step));
- }
- }
-
- /** A float slider's usable bounds and current value. */
- public static final class FloatRange {
- public final float min;
- public final float max;
- public final float value;
-
- FloatRange(float min, float max, float value) {
- this.min = min;
- this.max = max;
- this.value = value;
- }
- }
-
- private RuntimeConfigControl() {
- }
-
- /**
- * The int slider's bounds, step and value.
- *
- *
A malformed or absent bound falls back rather than throwing: a schema is external data, and
- * a slider that cannot be built is worse than one with a sane default. A step below 1 would make
- * {@link IntRange#steps()} divide by zero.
- */
- public static IntRange intRange(RuntimeConfigSchema.Node node, String currentValue) {
- int min = parseInt(node.minValue, 0);
- int max = Math.max(min, parseInt(node.maxValue, 100));
- int step = Math.max(1, parseInt(node.step, 1));
- String raw = currentValue != null && !currentValue.isEmpty()
- ? currentValue : node.defaultValue;
- int value = clamp(parseInt(raw, min), min, max);
- return new IntRange(min, max, step, value);
- }
-
- /** The float slider's bounds and value, with a malformed bound falling back to 0..1. */
- public static FloatRange floatRange(RuntimeConfigSchema.Node node, String currentValue) {
- float min = parseFloat(node.minValue, 0f);
- float max = parseFloat(node.maxValue, 1f);
- if (max < min) max = min;
- String raw = currentValue != null && !currentValue.isEmpty()
- ? currentValue : node.defaultValue;
- float value = clamp(parseFloat(raw, min), min, max);
- return new FloatRange(min, max, value);
- }
-
- /**
- * The choice control's style: an explicit style wins, otherwise four or fewer options render as
- * a segmented row and more as a dropdown.
- */
- public static String choiceStyle(RuntimeConfigSchema.Node node) {
- if (node.style != null && !node.style.isEmpty() && !"auto".equals(node.style)) {
- return node.style;
- }
- return node.options != null && node.options.size() <= 4 ? "segmented" : "dropdown";
- }
-
- /** The index of the option matching the current value, or 0 when nothing matches. */
- public static int choiceIndex(List options, String currentValue) {
- if (options == null) return 0;
- for (int i = 0; i < options.size(); i++) {
- if (options.get(i).value != null && options.get(i).value.equals(currentValue)) return i;
- }
- return 0;
- }
-
- /** The input length cap for a text node, or 0 when the schema sets none. */
- public static int maxLength(RuntimeConfigSchema.Node node) {
- return Math.max(0, node.maxLength);
- }
-
- /** Whether a text node should render as a multi-line field. */
- public static boolean multiline(RuntimeConfigSchema.Node node) {
- return "multiline_text".equals(node.type);
- }
-
- /** The value a toggle node represents, read from a stored string. */
- public static boolean toggleValue(String stored) {
- return "true".equalsIgnoreCase(stored) || "1".equals(stored)
- || "yes".equalsIgnoreCase(stored) || "on".equalsIgnoreCase(stored);
- }
-
- private static int parseInt(String value, int fallback) {
- try {
- return Integer.parseInt(value.trim());
- } catch (Exception ignored) {
- return fallback;
- }
- }
-
- private static float parseFloat(String value, float fallback) {
- try {
- return Float.parseFloat(value.trim());
- } catch (Exception ignored) {
- return fallback;
- }
- }
-
- private static int clamp(int value, int min, int max) {
- return Math.max(min, Math.min(max, value));
- }
-
- private static float clamp(float value, float min, float max) {
- return Math.max(min, Math.min(max, value));
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/manager/InbuiltModManager.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/manager/InbuiltModManager.java
index 461c4bc4..4dc4ac2c 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/manager/InbuiltModManager.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/manager/InbuiltModManager.java
@@ -41,7 +41,6 @@ public class InbuiltModManager {
private static final String KEY_MENU_SECTION_ORDER_PREFIX = "mod_menu_order_";
private static final String KEY_MOD_MENU_KEYBIND = "mod_menu_keybind";
private static final String KEY_MOD_MENU_CONTROLLER_BIND = "mod_menu_controller_bind";
- private static final String KEY_RADIAL_WHEEL_HOLD = "radial_wheel_hold_to_open";
private static final String KEY_PAUSE_MENU_ONLY = "pause_menu_only";
private static final String KEY_FAVORITE_MOD_KEYS = "favorite_mod_keys";
private static final String KEY_MOD_LOADOUTS = "mod_loadouts";
@@ -56,7 +55,6 @@ public class InbuiltModManager {
private static final String KEY_SNAPLOOK_RETURN_SPEED = "snaplook_return_speed";
private static final String KEY_CURSOR_SENSITIVITY = "cursor_sensitivity";
/** The radial wheel's centre dead zone, as a percentage of full stick travel. */
- private static final String KEY_WHEEL_CENTER_DEAD_ZONE = "wheel_center_dead_zone_percent";
private static final String KEY_GYRO_SENSITIVITY_X = "gyro_sensitivity_x";
private static final String KEY_GYRO_SENSITIVITY_Y = "gyro_sensitivity_y";
private static final String KEY_GYRO_INVERT_X = "gyro_invert_x";
@@ -181,7 +179,6 @@ public class InbuiltModManager {
/** The snap-look return-speed slider bounds. */
public static final int MAX_SNAPLOOK_RETURN_SPEED = 100;
private static final int DEFAULT_CURSOR_SENSITIVITY = 120;
- private static final int DEFAULT_WHEEL_DEAD_ZONE_PERCENT = 15;
private static final int DEFAULT_GYRO_SENSITIVITY = 100;
private static final int DEFAULT_GYRO_DEADZONE = 5;
private static final float DEFAULT_VOICE_RANGE = 24f;
@@ -514,20 +511,6 @@ public void setModMenuControllerBind(int keyCode) {
prefs.edit().putInt(KEY_MOD_MENU_CONTROLLER_BIND, Math.max(0, keyCode)).apply();
}
- /**
- * Whether the radial wheel opens on the bind's press and confirms on its release (hold-to-open),
- * or opens and closes on successive presses (toggle).
- *
- * Defaults to hold-to-open, the shape the update spec describes; the toggle remains available
- * for players who prefer it.
- */
- public boolean isRadialWheelHoldToOpen() {
- return prefs.getBoolean(KEY_RADIAL_WHEEL_HOLD, true);
- }
-
- public void setRadialWheelHoldToOpen(boolean hold) {
- prefs.edit().putBoolean(KEY_RADIAL_WHEEL_HOLD, hold).apply();
- }
/**
* Whether a key press should open the Mod Menu.
@@ -676,25 +659,6 @@ public void setCursorSensitivity(int sensitivity) {
prefs.edit().putInt(KEY_CURSOR_SENSITIVITY, Math.max(10, Math.min(300, sensitivity))).apply();
}
- /**
- * The radial wheel's centre dead zone, as a fraction of full stick travel.
- *
- *
Stored as a percentage so the settings slider has integers, and read through
- * {@link RadialWheelMotion#clampCenterDeadZone} so a stored value from an older build, or a
- * hand-edited preference file, cannot drive the cursor with an out-of-range dead zone.
- */
- public float getWheelCenterDeadZone() {
- int percent = prefs.getInt(KEY_WHEEL_CENTER_DEAD_ZONE, DEFAULT_WHEEL_DEAD_ZONE_PERCENT);
- return org.chimeramc.client.core.mods.inbuilt.overlay.RadialWheelMotion
- .clampCenterDeadZone(percent / 100f);
- }
-
- public void setWheelCenterDeadZone(float fraction) {
- float clamped = org.chimeramc.client.core.mods.inbuilt.overlay.RadialWheelMotion
- .clampCenterDeadZone(fraction);
- prefs.edit().putInt(KEY_WHEEL_CENTER_DEAD_ZONE, Math.round(clamped * 100f)).apply();
- }
-
public int getOverlayPositionX(String modId, int defaultX) {
return resolveAxis(modId, KEY_OVERLAY_POSITION_XF_PREFIX, KEY_OVERLAY_POSITION_X_PREFIX,
defaultX, true);
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/model/ModAvailability.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/model/ModAvailability.java
index d0611460..182d6b95 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/model/ModAvailability.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/model/ModAvailability.java
@@ -29,6 +29,12 @@ public static String unavailableReason(String modId) {
// factually wrong.
return REASON_NO_BACKEND;
}
+ if (ModIds.requiresHook(modId)) {
+ // Frame generation and motion blur act on the presented frame, so they need the engine's
+ // present/frame-boundary hook. That hook is a live probe, not a permanent gap: the module
+ // reads as unavailable only while the hook is absent on this build.
+ return REASON_NO_HOOK;
+ }
if (!ModIds.requiresGameData(modId)) return null;
// A catalogue entry is badged for one of two distinct causes, and telling them apart matters:
// an engine-hook module is waiting for a feed this build cannot yet read, while a
@@ -85,5 +91,13 @@ public static boolean isOptionInteractive(String modId, String configKey) {
*/
public static final String REASON_NO_BACKEND = "no_backend";
+ /**
+ * A stable id for the reason shown when a module needs a sweetloader hook (the engine's present
+ * boundary) that did not install on this build. Distinct from {@link #REASON_NO_GAME_DATA}: that
+ * is a feed no build can read, while this is a hook that is absent here but would un-badge the
+ * module the moment it installs.
+ */
+ public static final String REASON_NO_HOOK = "no_hook";
+
private ModAvailability() {}
}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/model/ModIds.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/model/ModIds.java
index 7f1e1e3e..34840cd0 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/model/ModIds.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/model/ModIds.java
@@ -55,6 +55,14 @@ public final class ModIds {
public static final String TICK_RATE_PREDICTOR = "tick_rate_predictor";
public static final String ADAPTIVE_FRAME_PACING = "adaptive_frame_pacing";
public static final String THERMAL_QUALITY_HOLD = "thermal_quality_hold";
+ /**
+ * Frame generation. There is exactly one honest route: the engine's present/frame-boundary hook
+ * (the {@code eglSwapBuffers} detour), which is where a second presented frame could be emitted.
+ * Gated on {@link #requiredHook} rather than the static {@link #requiresGameData} list, so the
+ * module un-badges on a build where the present seam is actually live instead of claiming the
+ * feed can never exist.
+ */
+ public static final String FRAME_GENERATION = "frame_generation";
// --- Visual / HUD / Misc modules (the mod-panel list) ------------------------------------
@@ -172,6 +180,29 @@ public static GameBackend.Feature requiredBackendFeature(String modId) {
return ZOOM.equals(modId) ? GameBackend.Feature.ZOOM : null;
}
+ /**
+ * The sweetloader hook a module needs before it can run, or null when it needs none.
+ *
+ *
Frame generation and motion blur are the two modules that act on the presented frame
+ * rather than on the launcher's own surface, so both need the engine's present/frame-boundary
+ * hook (the {@code eglSwapBuffers} detour). Unlike {@link #requiresGameData} — a static list of
+ * feeds this build can never read — this is a live probe: the module is badged while the hook is
+ * absent and becomes usable the moment the sweetloader installs it, so the menu tells the truth
+ * about the running build instead of a build-time guess.
+ */
+ public static org.chimeramc.client.core.backend.HookAvailability.Hook requiredHook(String modId) {
+ if (FRAME_GENERATION.equals(modId) || MOTION_BLUR.equals(modId)) {
+ return org.chimeramc.client.core.backend.HookAvailability.Hook.PRESENT;
+ }
+ return null;
+ }
+
+ /** True when the module needs a present/frame-boundary hook that is not live on this build. */
+ public static boolean requiresHook(String modId) {
+ org.chimeramc.client.core.backend.HookAvailability.Hook hook = requiredHook(modId);
+ return hook != null && !org.chimeramc.client.core.backend.HookAvailability.isLive(hook);
+ }
+
/**
* True when the active backend does not provide what the module needs.
*
@@ -220,7 +251,7 @@ public static boolean requiresGameData(String modId) {
// and List Players / Debug Menu (the local-player feed and the proximity-voice peer
// list).
|| TIME_CHANGER.equals(modId) || LOW_FIRE.equals(modId)
- || BLOCK_OUTLINE.equals(modId) || MOTION_BLUR.equals(modId)
+ || BLOCK_OUTLINE.equals(modId)
|| SATURATION.equals(modId) || ARMOR_VIEW_BENDING.equals(modId)
|| PROJECTILE_SCALER.equals(modId) || FREECAM.equals(modId)
|| TNT.equals(modId)
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/nativemod/FpsMod.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/nativemod/FpsMod.java
index f4fd84fd..c1d1e454 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/nativemod/FpsMod.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/nativemod/FpsMod.java
@@ -26,4 +26,14 @@ public static boolean nativeIsInitialized() {
public static int nativeGetFps() {
return GameBridge.readFps();
}
+
+ /**
+ * Whether {@link #nativeGetFps()} is the game's exact frame rate.
+ *
+ *
True only while the engine's present boundary is live and counting; an inferred tick reads
+ * as false so the overlay marks the number instead of passing it off as the game's exact rate.
+ */
+ public static boolean nativeIsFrameRateExact() {
+ return GameBridge.isFrameRateExact();
+ }
}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/BackdropRefreshSchedule.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/BackdropRefreshSchedule.java
index 3a540bb3..59af2cd8 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/BackdropRefreshSchedule.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/BackdropRefreshSchedule.java
@@ -3,8 +3,8 @@
/**
* The throttling rule for the wheel's backdrop capture (R3).
*
- *
The wheel sits over a live game, so its blurred backdrop is captured from the window rather
- * than drawn. Capturing every frame is the fastest way to lose the frame budget on a mid-range
+ *
A panel that sits over a live game captures its blurred backdrop from the window rather than
+ * drawing it. Capturing every frame is the fastest way to lose the frame budget on a mid-range
* Adreno, so the capture is rate-limited to a small number of refreshes per second and the last
* result is reused in between. This class holds only the arithmetic of "is a refresh due", because
* the timing rule is the part that is easy to get subtly wrong (a naive {@code now - last > period}
@@ -15,8 +15,10 @@
*/
public final class BackdropRefreshSchedule {
+ /** The most backdrop refreshes per second, so the capture cannot eat the frame budget. */
+ public static final int MAX_FPS = 10;
/** Never refresh faster than this, in milliseconds. */
- public static final long MIN_INTERVAL_MS = 1000L / RadialWheelMaterial.BLUR_FPS_MAX;
+ public static final long MIN_INTERVAL_MS = 1000L / MAX_FPS;
private long lastCaptureMs = Long.MIN_VALUE;
private int refreshes;
@@ -59,7 +61,7 @@ public void reset() {
*/
public static long intervalMs(int fps) {
if (fps <= 0) return -1L;
- int clamped = Math.min(RadialWheelMaterial.BLUR_FPS_MAX, fps);
+ int clamped = Math.min(MAX_FPS, fps);
return Math.max(MIN_INTERVAL_MS, 1000L / clamped);
}
}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ControllerProfileEditor.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ControllerProfileEditor.java
deleted file mode 100644
index 059bbf03..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ControllerProfileEditor.java
+++ /dev/null
@@ -1,594 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.content.Context;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
-import android.widget.LinearLayout;
-import android.widget.SeekBar;
-import android.widget.Spinner;
-import android.widget.TextView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.launcher.controller.ControllerProfile;
-import org.chimeramc.client.launcher.controller.ControllerProfileManager;
-import org.chimeramc.client.launcher.controller.ControllerRumble;
-import org.chimeramc.client.launcher.controller.ControllerType;
-import org.chimeramc.client.launcher.controller.RemapLayer;
-import org.chimeramc.client.launcher.controller.RumbleCurve;
-import org.chimeramc.client.launcher.controller.StickCurve;
-import org.chimeramc.client.launcher.controller.TriggerCurve;
-import org.chimeramc.client.ui.views.CurvePreviewView;
-import org.chimeramc.client.util.PersonalizationManager;
-import org.chimeramc.client.ui.dialogs.CustomAlertDialog;
-
-import java.util.List;
-
-/**
- * The full controller profile editor, shared by the launcher's Controller tab and the in-game VIP
- * menu's controller section.
- *
- *
It was extracted from {@code ControllerSettingsFragment} so the two call sites cannot drift:
- * the in-game screen used to offer only a subset (profile chips + two stick sliders), which is the
- * "controller settings are limiting" report. Now both open exactly the same editor — dead zones,
- * sensitivities, vibration and rumble with a test button, anti-drift, button-map layers, and the
- * left/right stick and trigger curves with a live preview.
- *
- *
Builds a plain {@link View} and shows it in a {@link CustomAlertDialog}, so it needs only a
- * {@link Context} and the profile manager, not a Fragment.
- */
-public final class ControllerProfileEditor {
-
- private final Context context;
- private final ControllerProfileManager profileManager;
-
- /** A capture in progress: the layer waiting for its modifier button. */
- private RemapLayer pendingLayerModifier;
- private Runnable layerModifierRefresh;
- private LinearLayout layerContainer;
-
- /** Notified after the profile is saved, so the caller can refresh its own view. */
- public interface SavedListener {
- void onSaved(ControllerProfile profile);
- }
-
- public ControllerProfileEditor(Context context, ControllerProfileManager profileManager) {
- this.context = context;
- this.profileManager = profileManager;
- }
-
- public boolean isCapturingModifier() {
- return pendingLayerModifier != null;
- }
-
- /**
- * Offers a hardware key press to a running modifier capture.
- *
- * @return true when the press was consumed by the capture (the caller must not also treat it as
- * a normal input).
- */
- public boolean handleHardwareKey(int keyCode) {
- if (pendingLayerModifier == null) return false;
- RemapLayer layer = pendingLayerModifier;
- pendingLayerModifier = null;
- layer.setModifierKeyCode(keyCode);
- if (layerModifierRefresh != null) layerModifierRefresh.run();
- return true;
- }
-
- /** Opens the editor for the active profile of a controller type. */
- public void show(ControllerType type, SavedListener onSaved) {
- if (type == null) type = ControllerType.XBOX;
- ControllerProfile profile = profileManager.getActiveProfile(type);
- ControllerProfile working = profile == null ? new ControllerProfile("Default") : profile.copy();
- LinearLayout content = new LinearLayout(context);
- content.setOrientation(LinearLayout.VERTICAL);
- content.setPadding(dp(8), dp(4), dp(8), dp(4));
-
- content.addView(label(string(R.string.controller_editor_name_label)));
- android.widget.EditText nameInput = new android.widget.EditText(context);
- nameInput.setText(working.getName());
- content.addView(nameInput);
-
- content.addView(label(string(R.string.controller_editor_deadzone_label)));
- SeekBar leftDz = seekBar(working.getLeftDeadZone());
- SeekBar rightDz = seekBar(working.getRightDeadZone());
- content.addView(label(string(R.string.controller_editor_left_deadzone)));
- content.addView(leftDz);
- content.addView(label(string(R.string.controller_editor_right_deadzone)));
- content.addView(rightDz);
-
- content.addView(label(string(R.string.controller_editor_sensitivity_label)));
- SeekBar leftSens = seekBarSens(working.getLeftStickSensitivity());
- SeekBar rightSens = seekBarSens(working.getRightStickSensitivity());
- content.addView(label(string(R.string.controller_editor_left_sensitivity)));
- content.addView(leftSens);
- content.addView(label(string(R.string.controller_editor_right_sensitivity)));
- content.addView(rightSens);
-
- android.widget.Switch vibration = new android.widget.Switch(context);
- vibration.setChecked(working.isVibrationEnabled());
- content.addView(label(string(R.string.controller_editor_vibration)));
- content.addView(vibration);
-
- content.addView(subLabel(string(R.string.controller_editor_rumble_strength)));
- final SeekBar rumbleBar = seekBar(working.getRumbleStrength() / 100f);
- final TextView rumbleValue = subLabel(
- context.getString(R.string.controller_editor_rumble_percent, working.getRumbleStrength()));
- content.addView(rumbleBar);
- content.addView(rumbleValue);
- final android.widget.Button rumbleTest = new android.widget.Button(context);
- rumbleTest.setText(R.string.controller_editor_rumble_test);
- content.addView(rumbleTest);
- rumbleBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar sb, int progress, boolean fromUser) {
- rumbleValue.setText(context.getString(
- R.string.controller_editor_rumble_percent, progress));
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar sb) {
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar sb) {
- }
- });
- final ControllerType rumbleType = type;
- rumbleTest.setOnClickListener(v -> {
- int percent = vibration.isChecked() ? rumbleBar.getProgress() : 0;
- if (RumbleCurve.isOff(percent)) {
- android.widget.Toast.makeText(context, R.string.controller_editor_rumble_off,
- android.widget.Toast.LENGTH_SHORT).show();
- return;
- }
- ControllerRumble.play(context, rumbleType, RumbleCurve.amplitude(percent));
- });
-
- android.widget.Switch antiDrift = new android.widget.Switch(context);
- antiDrift.setChecked(working.isAntiDriftEnabled());
- content.addView(label(string(R.string.controller_antidrift_title)));
- content.addView(subLabel(string(R.string.controller_antidrift_summary)));
- content.addView(antiDrift);
-
- content.addView(label(string(R.string.controller_layers_title)));
- content.addView(subLabel(string(R.string.controller_layers_summary)));
- layerContainer = new LinearLayout(context);
- layerContainer.setOrientation(LinearLayout.VERTICAL);
- content.addView(layerContainer);
- layerModifierRefresh = () -> rebuildLayerRows(working, layerContainer);
- rebuildLayerRows(working, layerContainer);
-
- content.addView(label(string(R.string.controller_editor_stick_curve_label)));
- CurvePreviewView preview = new CurvePreviewView(context);
- preview.setAccentColor(new PersonalizationManager(context).getAccentColor());
- preview.setProfile(working);
- content.addView(preview, new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, dp(140)));
-
- content.addView(label(string(R.string.controller_editor_left_curve)));
- StickCurve leftCurve = working.getLeftCurve();
- Spinner leftStickPreset = stickPresetSpinner(leftCurve);
- Spinner leftStickKind = stickKindSpinner(leftCurve.getKind());
- SeekBar leftStickExp = exponentBar(leftCurve.getExponent(),
- StickCurve.MIN_EXPONENT, StickCurve.MAX_EXPONENT);
- content.addView(subLabel(string(R.string.controller_editor_curve_preset)));
- content.addView(leftStickPreset);
- content.addView(subLabel(string(R.string.controller_editor_curve_kind)));
- content.addView(leftStickKind);
- content.addView(subLabel(string(R.string.controller_editor_curve_exponent)));
- content.addView(leftStickExp);
-
- content.addView(label(string(R.string.controller_editor_right_curve)));
- StickCurve rightCurve = working.getRightCurve();
- Spinner rightStickPreset = stickPresetSpinner(rightCurve);
- Spinner rightStickKind = stickKindSpinner(rightCurve.getKind());
- SeekBar rightStickExp = exponentBar(rightCurve.getExponent(),
- StickCurve.MIN_EXPONENT, StickCurve.MAX_EXPONENT);
- content.addView(subLabel(string(R.string.controller_editor_curve_preset)));
- content.addView(rightStickPreset);
- content.addView(subLabel(string(R.string.controller_editor_curve_kind)));
- content.addView(rightStickKind);
- content.addView(subLabel(string(R.string.controller_editor_curve_exponent)));
- content.addView(rightStickExp);
-
- content.addView(label(string(R.string.controller_editor_trigger_label)));
- content.addView(label(string(R.string.controller_editor_left_trigger)));
- TriggerCurve leftTrigger = working.getLeftTriggerCurve();
- Spinner leftTriggerPreset = triggerPresetSpinner(leftTrigger);
- SeekBar leftTriggerDz = deadZoneBar(leftTrigger.getDeadZone());
- SeekBar leftTriggerExp = exponentBar(leftTrigger.getExponent(),
- TriggerCurve.MIN_EXPONENT, TriggerCurve.MAX_EXPONENT);
- content.addView(subLabel(string(R.string.controller_editor_trigger_preset)));
- content.addView(leftTriggerPreset);
- content.addView(subLabel(string(R.string.controller_editor_trigger_deadzone)));
- content.addView(leftTriggerDz);
- content.addView(subLabel(string(R.string.controller_editor_trigger_exponent)));
- content.addView(leftTriggerExp);
-
- content.addView(label(string(R.string.controller_editor_right_trigger)));
- TriggerCurve rightTrigger = working.getRightTriggerCurve();
- Spinner rightTriggerPreset = triggerPresetSpinner(rightTrigger);
- SeekBar rightTriggerDz = deadZoneBar(rightTrigger.getDeadZone());
- SeekBar rightTriggerExp = exponentBar(rightTrigger.getExponent(),
- TriggerCurve.MIN_EXPONENT, TriggerCurve.MAX_EXPONENT);
- content.addView(subLabel(string(R.string.controller_editor_trigger_preset)));
- content.addView(rightTriggerPreset);
- content.addView(subLabel(string(R.string.controller_editor_trigger_deadzone)));
- content.addView(rightTriggerDz);
- content.addView(subLabel(string(R.string.controller_editor_trigger_exponent)));
- content.addView(rightTriggerExp);
-
- Runnable pushToPreview = () -> {
- working.setLeftCurve(new StickCurve(
- StickCurve.Kind.values()[leftStickKind.getSelectedItemPosition()],
- exponentFrom(leftStickExp, StickCurve.MIN_EXPONENT, StickCurve.MAX_EXPONENT)));
- working.setRightCurve(new StickCurve(
- StickCurve.Kind.values()[rightStickKind.getSelectedItemPosition()],
- exponentFrom(rightStickExp, StickCurve.MIN_EXPONENT, StickCurve.MAX_EXPONENT)));
- working.setLeftTriggerCurve(new TriggerCurve(
- deadZoneFrom(leftTriggerDz),
- exponentFrom(leftTriggerExp, TriggerCurve.MIN_EXPONENT, TriggerCurve.MAX_EXPONENT)));
- working.setRightTriggerCurve(new TriggerCurve(
- deadZoneFrom(rightTriggerDz),
- exponentFrom(rightTriggerExp, TriggerCurve.MIN_EXPONENT, TriggerCurve.MAX_EXPONENT)));
- preview.setProfile(working);
- };
- leftStickPreset.setOnItemSelectedListener(primedPresetListener(
- presetListener(leftStickKind, leftStickExp, pushToPreview)));
- rightStickPreset.setOnItemSelectedListener(primedPresetListener(
- presetListener(rightStickKind, rightStickExp, pushToPreview)));
- leftTriggerPreset.setOnItemSelectedListener(primedPresetListener(
- triggerPresetListener(leftTriggerDz, leftTriggerExp, pushToPreview)));
- rightTriggerPreset.setOnItemSelectedListener(primedPresetListener(
- triggerPresetListener(rightTriggerDz, rightTriggerExp, pushToPreview)));
- for (SeekBar bar : new SeekBar[]{leftStickExp, rightStickExp, leftTriggerDz, leftTriggerExp,
- rightTriggerDz, rightTriggerExp}) {
- bar.setOnSeekBarChangeListener(changeListener(pushToPreview));
- }
- for (Spinner spinner : new Spinner[]{leftStickKind, rightStickKind}) {
- spinner.setOnItemSelectedListener(selectionListener(pushToPreview));
- }
-
- android.widget.ScrollView scroller = new android.widget.ScrollView(context);
- scroller.addView(content);
- int maxHeight = (int) (context.getResources().getDisplayMetrics().heightPixels * 0.65f);
- scroller.setLayoutParams(new ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, Math.min(maxHeight, dp(560))));
-
- final ControllerType saveType = type;
- new CustomAlertDialog(context)
- .setTitleText(string(R.string.controller_edit))
- .setCustomView(scroller)
- .setPositiveButton(string(R.string.controller_save), v -> {
- working.setName(nameInput.getText().toString());
- working.setLeftDeadZone(leftDz.getProgress() / 100f);
- working.setRightDeadZone(rightDz.getProgress() / 100f);
- working.setLeftStickSensitivity(0.25f + leftSens.getProgress() / 100f * 2.75f);
- working.setRightStickSensitivity(0.25f + rightSens.getProgress() / 100f * 2.75f);
- working.setVibrationEnabled(vibration.isChecked());
- working.setRumbleStrength(rumbleBar.getProgress());
- working.setAntiDriftEnabled(antiDrift.isChecked());
- pushToPreview.run();
- int active = profileManager.getActiveSlot(saveType);
- List profiles = profileManager.getProfiles(saveType);
- if (active >= 0 && active < profiles.size()) {
- profiles.set(active, working);
- profileManager.saveProfiles(saveType, profiles);
- org.chimeramc.client.launcher.controller.ControllerInputProcessor
- .setActiveProfile(saveType, working);
- }
- org.chimeramc.client.ui.animation.UiTouchFeedback.confirm(context);
- if (onSaved != null) onSaved.onSaved(working);
- })
- .setNegativeButton(string(R.string.controller_cancel), v -> {
- })
- .show();
- }
-
- // --- Helpers (ported from ControllerSettingsFragment) ------------------------------------
-
- private void rebuildLayerRows(ControllerProfile working, LinearLayout container) {
- if (container == null) return;
- container.removeAllViews();
-
- for (int i = 0; i < working.getRemapLayers().size(); i++) {
- final int index = i;
- RemapLayer layer = working.getRemapLayers().get(i);
- LinearLayout row = new LinearLayout(context);
- row.setOrientation(LinearLayout.HORIZONTAL);
- row.setGravity(android.view.Gravity.CENTER_VERTICAL);
-
- TextView name = new TextView(context);
- name.setText(context.getString(R.string.controller_layer_row, layer.getName(),
- modifierLabel(layer.getModifierKeyCode())));
- name.setTextSize(12f);
- name.setTextColor(context.getColor(R.color.on_surface));
- row.addView(name, new LinearLayout.LayoutParams(
- 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
-
- android.widget.Button set = new android.widget.Button(context);
- set.setText(R.string.controller_layer_set);
- set.setTextSize(11f);
- set.setOnClickListener(v -> startLayerModifierCapture(layer));
- row.addView(set);
-
- android.widget.Button clear = new android.widget.Button(context);
- clear.setText(R.string.controller_layer_clear);
- clear.setTextSize(11f);
- clear.setOnClickListener(v -> {
- layer.setModifierKeyCode(RemapLayer.NO_MODIFIER);
- rebuildLayerRows(working, container);
- });
- row.addView(clear);
-
- android.widget.Button remove = new android.widget.Button(context);
- remove.setText(R.string.controller_layer_delete);
- remove.setTextSize(11f);
- remove.setOnClickListener(v -> {
- working.removeRemapLayer(index);
- rebuildLayerRows(working, container);
- });
- row.addView(remove);
-
- container.addView(row);
- }
-
- android.widget.Button add = new android.widget.Button(context);
- add.setText(R.string.controller_layer_add);
- add.setOnClickListener(v -> {
- RemapLayer layer = new RemapLayer(context.getString(
- R.string.controller_layer_default_name, working.getRemapLayers().size() + 1));
- layer.getRemaps().putAll(working.getButtonRemaps());
- working.addRemapLayer(layer);
- rebuildLayerRows(working, container);
- });
- container.addView(add);
- }
-
- private void startLayerModifierCapture(RemapLayer layer) {
- pendingLayerModifier = layer;
- android.widget.Toast.makeText(context, R.string.controller_layer_press_modifier,
- android.widget.Toast.LENGTH_SHORT).show();
- }
-
- private String modifierLabel(int keyCode) {
- if (keyCode == RemapLayer.NO_MODIFIER) {
- return context.getString(R.string.controller_layer_no_modifier);
- }
- String name = android.view.KeyEvent.keyCodeToString(keyCode);
- return name.startsWith("KEYCODE_") ? name.substring(8).replace('_', ' ') : name;
- }
-
- private AdapterView.OnItemSelectedListener presetListener(Spinner kindSpinner,
- SeekBar exponentBar,
- Runnable onChange) {
- return new AdapterView.OnItemSelectedListener() {
- @Override
- public void onItemSelected(AdapterView> parent, View view, int position, long id) {
- StickCurve.Preset preset = StickCurve.Preset.values()[position];
- kindSpinner.setSelection(preset.toCurve().getKind().ordinal());
- exponentBar.setProgress(progressFor(preset.toCurve().getExponent(),
- StickCurve.MIN_EXPONENT, StickCurve.MAX_EXPONENT));
- onChange.run();
- }
-
- @Override
- public void onNothingSelected(AdapterView> parent) {
- }
- };
- }
-
- private AdapterView.OnItemSelectedListener triggerPresetListener(SeekBar deadZoneBar,
- SeekBar exponentBar,
- Runnable onChange) {
- return new AdapterView.OnItemSelectedListener() {
- @Override
- public void onItemSelected(AdapterView> parent, View view, int position, long id) {
- TriggerCurve.Preset preset = TriggerCurve.Preset.values()[position];
- TriggerCurve curve = preset.toCurve();
- deadZoneBar.setProgress((int) (curve.getDeadZone() * 100f));
- exponentBar.setProgress(progressFor(curve.getExponent(),
- TriggerCurve.MIN_EXPONENT, TriggerCurve.MAX_EXPONENT));
- onChange.run();
- }
-
- @Override
- public void onNothingSelected(AdapterView> parent) {
- }
- };
- }
-
- /**
- * Swallows the preset spinner's first callback so it cannot clobber a custom curve: a Spinner
- * always fires once after layout at whatever position is selected, and a custom curve sits on
- * position 0. The controls are initialised from the curve first, so dropping it loses nothing.
- */
- private AdapterView.OnItemSelectedListener primedPresetListener(
- AdapterView.OnItemSelectedListener delegate) {
- return new AdapterView.OnItemSelectedListener() {
- private boolean primed = false;
-
- @Override
- public void onItemSelected(AdapterView> parent, View view, int position, long id) {
- if (!primed) {
- primed = true;
- return;
- }
- delegate.onItemSelected(parent, view, position, id);
- }
-
- @Override
- public void onNothingSelected(AdapterView> parent) {
- delegate.onNothingSelected(parent);
- }
- };
- }
-
- private static int progressFor(float value, float min, float max) {
- float clamped = Math.max(min, Math.min(max, value));
- return (int) (((clamped - min) / (max - min)) * 100f);
- }
-
- private SeekBar.OnSeekBarChangeListener changeListener(Runnable onChange) {
- return new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- onChange.run();
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
- }
- };
- }
-
- private AdapterView.OnItemSelectedListener selectionListener(Runnable onChange) {
- return new AdapterView.OnItemSelectedListener() {
- @Override
- public void onItemSelected(AdapterView> parent, View view, int position, long id) {
- onChange.run();
- }
-
- @Override
- public void onNothingSelected(AdapterView> parent) {
- }
- };
- }
-
- private TextView label(String text) {
- TextView tv = new TextView(context);
- tv.setText(text);
- tv.setTextSize(13f);
- tv.setTextColor(context.getColor(R.color.on_surface));
- tv.setPadding(0, dp(6), 0, dp(2));
- return tv;
- }
-
- private TextView subLabel(String text) {
- TextView tv = new TextView(context);
- tv.setText(text);
- tv.setTextSize(12f);
- tv.setTextColor(context.getColor(R.color.text_secondary));
- tv.setPadding(0, dp(4), 0, 0);
- return tv;
- }
-
- private SeekBar seekBar(float value) {
- SeekBar sb = new SeekBar(context);
- sb.setMax(100);
- sb.setProgress((int) (value * 100f));
- return sb;
- }
-
- private SeekBar seekBarSens(float value) {
- SeekBar sb = new SeekBar(context);
- sb.setMax(100);
- float scaled = (value - 0.25f) / 2.75f;
- sb.setProgress((int) (scaled * 100f));
- return sb;
- }
-
- private SeekBar exponentBar(float value, float min, float max) {
- SeekBar sb = new SeekBar(context);
- sb.setMax(100);
- float clamped = Math.max(min, Math.min(max, value));
- sb.setProgress((int) (((clamped - min) / (max - min)) * 100f));
- return sb;
- }
-
- private float exponentFrom(SeekBar bar, float min, float max) {
- return min + (bar.getProgress() / 100f) * (max - min);
- }
-
- private SeekBar deadZoneBar(float value) {
- SeekBar sb = new SeekBar(context);
- sb.setMax(100);
- sb.setProgress((int) (TriggerCurve.clampDeadZone(value) * 100f));
- return sb;
- }
-
- private float deadZoneFrom(SeekBar bar) {
- return bar.getProgress() / 100f;
- }
-
- private Spinner stickPresetSpinner(StickCurve curve) {
- Spinner spinner = new Spinner(context);
- StickCurve.Preset[] presets = StickCurve.Preset.values();
- String[] names = new String[presets.length];
- for (int i = 0; i < names.length; i++) names[i] = stickPresetName(presets[i]);
- spinner.setAdapter(new ArrayAdapter<>(context,
- android.R.layout.simple_spinner_dropdown_item, names));
- StickCurve.Preset match = curve == null ? null : curve.matchingPreset();
- if (match != null) spinner.setSelection(match.ordinal());
- return spinner;
- }
-
- private Spinner stickKindSpinner(StickCurve.Kind kind) {
- Spinner spinner = new Spinner(context);
- StickCurve.Kind[] kinds = StickCurve.Kind.values();
- String[] names = new String[kinds.length];
- for (int i = 0; i < names.length; i++) names[i] = stickKindName(kinds[i]);
- spinner.setAdapter(new ArrayAdapter<>(context,
- android.R.layout.simple_spinner_dropdown_item, names));
- spinner.setSelection(kind == null ? 0 : kind.ordinal());
- return spinner;
- }
-
- private Spinner triggerPresetSpinner(TriggerCurve curve) {
- Spinner spinner = new Spinner(context);
- TriggerCurve.Preset[] presets = TriggerCurve.Preset.values();
- String[] names = new String[presets.length];
- for (int i = 0; i < names.length; i++) names[i] = triggerPresetName(presets[i]);
- spinner.setAdapter(new ArrayAdapter<>(context,
- android.R.layout.simple_spinner_dropdown_item, names));
- TriggerCurve.Preset match = curve == null ? null : curve.matchingPreset();
- if (match != null) spinner.setSelection(match.ordinal());
- return spinner;
- }
-
- private String stickPresetName(StickCurve.Preset preset) {
- switch (preset) {
- case PRECISION: return string(R.string.controller_preset_precision);
- case BALANCED: return string(R.string.controller_preset_balanced);
- case AGGRESSIVE: return string(R.string.controller_preset_aggressive);
- case INSTANT: return string(R.string.controller_preset_instant);
- case LINEAR:
- default: return string(R.string.controller_preset_linear);
- }
- }
-
- private String stickKindName(StickCurve.Kind kind) {
- switch (kind) {
- case EXPONENTIAL: return string(R.string.controller_curve_kind_exponential);
- case EASE_OUT: return string(R.string.controller_curve_kind_ease_out);
- case SIGMOID: return string(R.string.controller_curve_kind_sigmoid);
- case LINEAR:
- default: return string(R.string.controller_curve_kind_linear);
- }
- }
-
- private String triggerPresetName(TriggerCurve.Preset preset) {
- switch (preset) {
- case SOFT: return string(R.string.controller_trigger_preset_soft);
- case HAIR_TRIGGER: return string(R.string.controller_trigger_preset_hair);
- case DELIBERATE: return string(R.string.controller_trigger_preset_deliberate);
- case LINEAR:
- default: return string(R.string.controller_trigger_preset_linear);
- }
- }
-
- private String string(int res) {
- return context.getString(res);
- }
-
- private int dp(int value) {
- return (int) (value * context.getResources().getDisplayMetrics().density + 0.5f);
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/CosmeticsPanel.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/CosmeticsPanel.java
index 915da525..882940e0 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/CosmeticsPanel.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/CosmeticsPanel.java
@@ -1069,7 +1069,7 @@ public View getView() {
return root;
}
- /** Stops the preview's animation. Kept for the ModMenuOverlay hide path. */
+ /** Stops the preview's animation. Kept for the host's hide path. */
public void onHidden() {
if (preview != null) preview.stopPreview();
}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/CustomizeChooser.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/CustomizeChooser.java
deleted file mode 100644
index aca86e41..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/CustomizeChooser.java
+++ /dev/null
@@ -1,161 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.app.Activity;
-import android.app.Dialog;
-import android.graphics.Color;
-import android.graphics.drawable.ColorDrawable;
-import android.graphics.drawable.GradientDrawable;
-import android.view.Gravity;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.Window;
-import android.view.WindowManager;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
-import android.widget.TextView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.ui.animation.DynamicAnim;
-import org.chimeramc.client.ui.components.UiTokens;
-
-/**
- * The radial wheel's CLOSET ("customize") chooser.
- *
- * The sector asks a question rather than picking for the player: a small rounded panel, lifted
- * above the screen centre, with a header and two icon buttons — the Mod Menu's settings page and the
- * cosmetics wardrobe. Each button carries the exact drawable the Mod Menu's own rail uses for that
- * page ({@link ModMenuPage#getIconRes()}) and that page's title, so the choice is recognisable from
- * the wheel alone and cannot drift from the tab it opens.
- *
- *
It is a plain {@link Dialog} rather than an overlay window, so it inherits the standard
- * back-dismiss and dim behaviour and needs no manual attach/detach bookkeeping. The two callbacks
- * are plain {@link Runnable}s, so the caller owns the routing and this class stays a pure surface.
- */
-public class CustomizeChooser extends Dialog {
-
- private final Activity activity;
- private final Runnable onSettings;
- private final Runnable onCosmetics;
-
- public CustomizeChooser(Activity activity, Runnable onSettings, Runnable onCosmetics) {
- super(activity);
- this.activity = activity;
- this.onSettings = onSettings;
- this.onCosmetics = onCosmetics;
- }
-
- @Override
- protected void onCreate(android.os.Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- requestWindowFeature(Window.FEATURE_NO_TITLE);
- setContentView(buildContent());
- setCanceledOnTouchOutside(true);
-
- Window window = getWindow();
- if (window != null) {
- window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
- window.setLayout(WindowManager.LayoutParams.WRAP_CONTENT,
- WindowManager.LayoutParams.WRAP_CONTENT);
- // Lifted above the centre: the wheel's hub sits at the screen centre, so a centred
- // panel would cover it and read as part of the wheel. The offset is a fraction of the
- // screen height rather than a fixed dp, so the gap looks the same on a phone and a
- // tablet.
- window.setGravity(Gravity.CENTER);
- WindowManager.LayoutParams attrs = window.getAttributes();
- android.util.DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
- attrs.y = -Math.round(metrics.heightPixels * 0.16f);
- attrs.dimAmount = 0.45f;
- window.setAttributes(attrs);
- window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
- }
- }
-
- private View buildContent() {
- int accent = UiTokens.resolveAccent(activity);
- int pad = dp(UiTokens.SPACE_4);
-
- LinearLayout root = new LinearLayout(activity);
- root.setOrientation(LinearLayout.VERTICAL);
- root.setPadding(pad, pad, pad, pad);
- GradientDrawable panel = new GradientDrawable();
- panel.setShape(GradientDrawable.RECTANGLE);
- panel.setCornerRadius(dp(UiTokens.RADIUS_XLARGE));
- panel.setColor(UiTokens.BG_SURFACE);
- panel.setStroke(Math.max(1, dp(1)), UiTokens.STROKE_HAIRLINE);
- root.setBackground(panel);
-
- TextView title = new TextView(activity);
- title.setText(R.string.wheel_customize_title);
- title.setTextColor(UiTokens.TEXT_PRIMARY);
- title.setTextSize(15f);
- title.setTypeface(null, android.graphics.Typeface.BOLD);
- title.setGravity(Gravity.CENTER_HORIZONTAL);
- title.setPadding(pad, dp(UiTokens.SPACE_2), pad, pad);
- root.addView(title);
-
- LinearLayout buttons = new LinearLayout(activity);
- buttons.setOrientation(LinearLayout.HORIZONTAL);
- buttons.setGravity(Gravity.CENTER);
-
- buttons.addView(choiceButton(ModMenuPage.SETTINGS, accent, () -> {
- dismiss();
- if (onSettings != null) onSettings.run();
- }));
- LinearLayout.LayoutParams cosmeticsParams = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- cosmeticsParams.leftMargin = dp(UiTokens.SPACE_3);
- View cosmetics = choiceButton(ModMenuPage.COSMETICS, accent, () -> {
- dismiss();
- if (onCosmetics != null) onCosmetics.run();
- });
- cosmetics.setLayoutParams(cosmeticsParams);
- buttons.addView(cosmetics);
-
- root.addView(buttons);
- return root;
- }
-
- /**
- * One choice, built from a Mod Menu page so its icon and label are the page's own.
- *
- *
Taking the {@link ModMenuPage} rather than a loose icon keeps the two in step: renaming a
- * page or swapping its drawable updates this chooser for free, which is the property that stops
- * the wheel teaching a glyph the tab no longer uses.
- */
- private View choiceButton(ModMenuPage page, int accent, Runnable action) {
- LinearLayout button = new LinearLayout(activity);
- button.setOrientation(LinearLayout.VERTICAL);
- button.setGravity(Gravity.CENTER);
- button.setPadding(dp(UiTokens.SPACE_4), dp(UiTokens.SPACE_3),
- dp(UiTokens.SPACE_4), dp(UiTokens.SPACE_3));
- button.setMinimumWidth(dp(132));
- GradientDrawable bg = new GradientDrawable();
- bg.setShape(GradientDrawable.RECTANGLE);
- bg.setCornerRadius(dp(UiTokens.RADIUS_CARD));
- bg.setColor(UiTokens.BG_SURFACE_HOVER);
- bg.setStroke(Math.max(1, dp(1)), accent);
- button.setBackground(bg);
-
- ImageView iconView = new ImageView(activity);
- int iconSize = dp(28);
- button.addView(iconView, new LinearLayout.LayoutParams(iconSize, iconSize));
- iconView.setImageResource(page.getIconRes());
- iconView.setImageTintList(android.content.res.ColorStateList.valueOf(accent));
-
- TextView label = new TextView(activity);
- label.setText(page.getTitleRes());
- label.setTextColor(UiTokens.TEXT_PRIMARY);
- label.setTextSize(12f);
- label.setGravity(Gravity.CENTER_HORIZONTAL);
- label.setPadding(0, dp(UiTokens.SPACE_2), 0, 0);
- button.addView(label);
-
- button.setOnClickListener(v -> action.run());
- DynamicAnim.applyPressScale(button);
- return button;
- }
-
- private int dp(int value) {
- return Math.round(value * activity.getResources().getDisplayMetrics().density);
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/DebugMenuOverlay.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/DebugMenuOverlay.java
index c8c6219a..6c45f9d0 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/DebugMenuOverlay.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/DebugMenuOverlay.java
@@ -28,6 +28,12 @@
*/
public class DebugMenuOverlay extends TextOverlay {
+ /**
+ * The launcher's own {@code Choreographer} cadence. Used only for the host-side avg/low and the
+ * "launcher refresh" note; the headline FPS line takes the game's own render-tick rate through
+ * {@link org.chimeramc.client.core.backend.GameBridge} so a debug screenshot never shows the
+ * launcher's vsync tick as the game's frame rate.
+ */
private final FrameRateMeter frameMeter = new FrameRateMeter();
public DebugMenuOverlay(Activity activity) {
@@ -65,8 +71,19 @@ protected String buildText() {
s.clientVersion = BuildConfig.VERSION_NAME;
s.gameVersion = selectedGameVersion();
s.abi = primaryAbi();
- s.fps = frameMeter.currentFps();
- s.onePercentLowFps = frameMeter.currentOnePercentLowFps();
+ // The headline rate is the game's own render-tick rate when the backend can produce one; the
+ // launcher's Choreographer cadence is a different measurement and is only shown, named, when
+ // the game's rate is unavailable.
+ int gameFps = org.chimeramc.client.core.backend.GameBridge.readFps();
+ boolean exact = org.chimeramc.client.core.backend.GameBridge.isFrameRateExact();
+ if (gameFps > 0) {
+ s.fps = gameFps;
+ s.fpsIsApproximate = !exact;
+ } else {
+ s.fps = frameMeter.currentFps();
+ s.fpsIsLauncherRefresh = s.fps > 0;
+ }
+ s.onePercentLowFps = s.fpsIsLauncherRefresh ? frameMeter.currentOnePercentLowFps() : 0;
s.position = PreloaderInput.readLocalPlayerPosition();
s.rotation = PreloaderInput.readLocalPlayerRotation();
s.health = PreloaderInput.readLocalPlayerHealth();
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FpsDisplayOverlay.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FpsDisplayOverlay.java
index d86b3954..44f72813 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FpsDisplayOverlay.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FpsDisplayOverlay.java
@@ -17,6 +17,7 @@
import android.widget.TextView;
import org.chimeramc.client.R;
+import org.chimeramc.client.databinding.OverlayHudReadoutBinding;
import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
import org.chimeramc.client.core.mods.inbuilt.model.ModIds;
import org.chimeramc.client.launcher.core.mods.inbuilt.nativemod.FpsMod;
@@ -33,8 +34,10 @@ public class FpsDisplayOverlay {
private final Handler handler = MainThread.handler();
private View overlayView;
+ private OverlayHudReadoutBinding binding;
private TextView statsText;
private TextView hudStats;
+ private TextView hudApprox;
private View hudAccent;
private FrameTimeGraphView graphView;
private boolean statsEnabled;
@@ -44,10 +47,11 @@ public class FpsDisplayOverlay {
private boolean initialized = false;
/**
- * Host-side fallback meter. The native {@code FpsMod} only reports when its hook resolves a
- * game address, which not every build does; without this the readout stayed on "FPS: --"
- * forever. The Choreographer count works on any build and is what the overlay shows when the
- * native value is unavailable.
+ * The launcher's own {@code Choreographer} cadence. It is a liveness reading of the launcher
+ * main thread's vsync tick, not the game's frame rate, so it is never shown under the "FPS"
+ * unit: when the game's own render-tick rate is unavailable the readout shows an honest
+ * unavailable state and names this cadence as "launcher refresh" instead. It also feeds the
+ * frame-time graph and the avg/low figures, which describe this cadence, not the game's path.
*/
private final FrameRateMeter fallbackMeter = new FrameRateMeter();
private final OverlayRefreshGate refreshGate =
@@ -108,11 +112,13 @@ private void inflateOverlay() {
InbuiltModManager manager = InbuiltModManager.getInstance(activity);
statsEnabled = manager.isFpsStatsEnabled(ModIds.FPS_DISPLAY);
boolean graph = manager.isFrameTimeGraphEnabled(ModIds.FPS_DISPLAY);
- overlayView = LayoutInflater.from(activity).inflate(R.layout.overlay_hud_readout, null);
- statsText = overlayView.findViewById(R.id.stats_text);
- hudStats = overlayView.findViewById(R.id.hud_stats);
- hudAccent = overlayView.findViewById(R.id.hud_accent);
- graphView = overlayView.findViewById(R.id.frame_time_graph);
+ binding = OverlayHudReadoutBinding.inflate(LayoutInflater.from(activity));
+ overlayView = binding.getRoot();
+ statsText = binding.statsText;
+ hudStats = binding.hudStats;
+ hudAccent = binding.hudAccent;
+ hudApprox = binding.hudApprox;
+ graphView = binding.frameTimeGraph;
if (graphView != null) {
graphView.setVisibility(graph ? View.VISIBLE : View.GONE);
if (graph) {
@@ -208,19 +214,29 @@ private void refreshRuntimeVisibility() {
private void updateDisplay() {
if (statsText == null) return;
- int fps = nativeFps();
- if (fps <= 0 && fallbackMeter.isRunning() && fallbackFps > 0) {
- // The native hook did not resolve on this build; show the host's own present rate
- // rather than a permanent "--", which read as a broken module.
- fps = fallbackFps;
- }
- // The timer runs four times a second but the readout changes far less often, so the
- // text is built and set only when the number actually differs.
- if (refreshGate.shouldRefresh(Integer.toString(fps), SystemClock.uptimeMillis())) {
- HudThreshold.Level level = HudThreshold.levelForFps(fps);
- statsText.setText(fps > 0
- ? Integer.toString(fps)
+ // Three readings, and they are not the same number: the game's exact rate (present boundary
+ // live), the game's inferred rate (present boundary absent), and the launcher's own refresh
+ // cadence. FpsReadout owns which one wins and whether it must be marked; the overlay only
+ // renders the choice. Crucially, only a reading from the game's own render path is shown
+ // under the "FPS" unit — the launcher's vsync cadence is named separately, never passed off
+ // as the game's frame rate.
+ int hostFps = fallbackMeter.isRunning() ? fallbackFps : 0;
+ FpsReadout readout = FpsReadout.of(nativeFps(), nativeExact(), hostFps);
+ int gameFps = readout.gameValue();
+ // The gate keys on what is actually shown, so a change in the note alone (e.g. the launcher
+ // cadence ticking) still refreshes the panel.
+ String gate = gameFps > 0 ? readout.text() : "refresh:" + readout.launcherRefreshHz();
+ if (refreshGate.shouldRefresh(gate, SystemClock.uptimeMillis())) {
+ HudThreshold.Level level = HudThreshold.levelForFps(gameFps);
+ statsText.setText(gameFps > 0
+ ? Integer.toString(gameFps)
: activity.getString(R.string.mod_overlay_fps_no_reading));
+ // The "~" marker is the one cue that the number is not the game's exact frame rate. It is
+ // shown only beside a real game rate; with no game rate there is no number to mark.
+ if (hudApprox != null) {
+ hudApprox.setVisibility(gameFps > 0 && readout.approximate()
+ ? View.VISIBLE : View.GONE);
+ }
// The level is carried by the accent bar's colour and by the shape cue on the stats
// line, never by colouring the number: a coloured number is hard to read on a bright
// scene and says nothing at all to a colour-blind player.
@@ -228,13 +244,8 @@ private void updateDisplay() {
hudAccent.setBackgroundColor(HudThreshold.colourFor(level));
}
if (hudStats != null) {
- String stats = statsEnabled ? fpsStatsText() : null;
- String cue = HudThreshold.cue(level);
- String line = stats == null ? "" : stats;
- if (!cue.isEmpty() && !line.isEmpty()) line = cue + " " + line;
- else if (!cue.isEmpty()) line = cue;
- hudStats.setText(line);
- hudStats.setVisibility(line.isEmpty() ? View.GONE : View.VISIBLE);
+ hudStats.setText(statsLine(readout, gameFps, level));
+ hudStats.setVisibility(hudStats.getText().length() == 0 ? View.GONE : View.VISIBLE);
}
if (graphView != null) {
graphView.refresh();
@@ -243,6 +254,28 @@ private void updateDisplay() {
}
}
+ /**
+ * The second line: the shape cue beside a real game rate, or — when there is no game rate yet —
+ * a note naming the launcher's own refresh cadence so the panel still shows the launcher is
+ * alive without inventing a frame rate.
+ *
+ *
The avg/low figures come from the launcher's own {@code Choreographer} history, which is
+ * the launcher's cadence, not the game's render path, so they are shown only alongside that
+ * cadence note and never attached to a game frame rate they do not describe.
+ */
+ private String statsLine(FpsReadout readout, int gameFps, HudThreshold.Level level) {
+ if (gameFps > 0) {
+ return HudThreshold.cue(level);
+ }
+ int hz = readout.launcherRefreshHz();
+ if (hz <= 0) {
+ return activity.getString(R.string.mod_overlay_fps_waiting);
+ }
+ String note = activity.getString(R.string.mod_overlay_fps_launcher_note) + " " + hz + " Hz";
+ String stats = statsEnabled ? fpsStatsText() : null;
+ return stats == null ? note : note + " · " + stats;
+ }
+
/**
* The average and 1% low over the configured window, as "60 avg / 41 low", or null
* when there is not yet a reading. Computed from the same history the graph draws, so the two
@@ -270,6 +303,18 @@ private void startFallbackMeter() {
fallbackMeter.start(fps -> fallbackFps = fps);
}
+ /**
+ * Whether the native reading is the game's exact rate. Only the live present boundary is exact;
+ * an inferred tick reads as approximate so the number is marked rather than passed off as exact.
+ */
+ private boolean nativeExact() {
+ try {
+ return initialized && FpsMod.nativeIsFrameRateExact();
+ } catch (Throwable ignored) {
+ return false;
+ }
+ }
+
/** The native FPS, or 0 when the hook is not available/initialised. */
private int nativeFps() {
try {
@@ -461,8 +506,10 @@ public void hide() {
AppLog.warnOnce("CaramelClient", "FpsDisplayOverlay:442", "Ignored a failure at FpsDisplayOverlay:442", ignored);
}
overlayView = null;
+ binding = null;
statsText = null;
hudStats = null;
+ hudApprox = null;
hudAccent = null;
graphView = null;
statsEnabled = false;
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FrameRateMeter.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FrameRateMeter.java
index 9f335724..ec711829 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FrameRateMeter.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FrameRateMeter.java
@@ -10,9 +10,12 @@
* meter counts the display's own vsync callbacks through {@link Choreographer}, which needs no
* native hook and works on any build the launcher runs on.
*
- *
What the number means. This is the host's present rate, i.e. the rate the game's
- * frames are being delivered to the display on this process, not an internal game counter. It is
- * the honest reading available without a native feed; the overlay's own label does not claim more.
+ *
What the number means. This is the launcher's own refresh cadence: the rate at which
+ * the launcher's main thread receives {@code Choreographer} vsync ticks. It is
+ * not the game's frame rate and not the game's present rate. The launcher does not own the
+ * game's {@code eglSwapBuffers}, so the meter keeps counting at the display refresh cadence while
+ * the game is paused, loading or stalled. It is a liveness reading of last resort, and the overlay
+ * labels it as the launcher refresh so it is never passed off as the game's frame rate.
*
*
Allocation-free on the frame path: a fixed ring of frame timestamps and a rolling one-second
* window. {@link #post()} must be called from the main thread; the callback re-arms itself while
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/GroupedMods.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/GroupedMods.java
deleted file mode 100644
index 1f9d140c..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/GroupedMods.java
+++ /dev/null
@@ -1,41 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import org.chimeramc.client.core.mods.inbuilt.UnifiedMod;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * Splits a module list into favorites and the rest, each sorted by name.
- *
- *
Extracted from {@code ModMenuOverlay} as a top-level type: the grouping rule is pure (it reads
- * only a mod's favorite flag and name) and had no reason to be a nested detail of a 3,400-line
- * class. Kept package-private because only the menu uses it.
- *
- *
The package is {@code ...core.mods.inbuilt.overlay} to match the sibling sources in this
- * directory, not the {@code ...launcher.core...} path the file sits under.
- */
-final class GroupedMods {
-
- private final List favorites = new ArrayList<>();
- private final List others = new ArrayList<>();
-
- void add(UnifiedMod mod, boolean favorite) {
- if (favorite) {
- favorites.add(mod);
- } else {
- others.add(mod);
- }
- }
-
- void appendTo(List target) {
- sort(favorites);
- sort(others);
- target.addAll(favorites);
- target.addAll(others);
- }
-
- private void sort(List mods) {
- mods.sort((left, right) -> left.getName().compareToIgnoreCase(right.getName()));
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/HudEditorController.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/HudEditorController.java
deleted file mode 100644
index bac87971..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/HudEditorController.java
+++ /dev/null
@@ -1,431 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.app.Activity;
-import android.view.Gravity;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewConfiguration;
-import android.view.ViewGroup;
-import android.view.WindowManager;
-import android.widget.FrameLayout;
-import android.widget.SeekBar;
-import android.widget.TextView;
-
-import org.chimeramc.client.R;
-
-/**
- * The Mod Menu's HUD editor: the drag handle, the safe-area guide, the button size/opacity controls
- * and the mode switch.
- *
- * Extracted from {@code ModMenuOverlay} to give that class real headroom under the file-size
- * budget. The editor is a coherent unit -- it owns the HUD tool views, the two "we are updating the
- * slider programmatically" guards, and the window-parameter dance that shrinks the overlay to a
- * floating tool window and restores it -- so it belongs in one place rather than inline in the menu.
- *
- *
The controller does not own the window. It reads the current {@link WindowManager} and its
- * {@link WindowManager.LayoutParams} from the {@link Host} on every show, because the menu
- * reassigns those each time it opens; holding a stale copy would move a window that no longer
- * exists.
- *
- *
The package is {@code ...core.mods.inbuilt.overlay} to match the sibling sources in this
- * directory, not the {@code ...launcher.core...} path the file sits under.
- */
-final class HudEditorController {
-
- /** The menu-side effects the editor cannot perform itself. */
- interface Host {
- /** Switch the navigation highlight to the HUD section. */
- void onEditorNavigation();
-
- /** Return the menu to the module list. */
- void onEditorClosed();
-
- /** Play the menu's entrance animation on the container the editor restored. */
- void animateMenuEnter(View menuContainer);
- }
-
- private final Activity activity;
- private final Host host;
-
- private View overlayView;
- private WindowManager windowManager;
- private WindowManager.LayoutParams wmParams;
-
- private View hudEditorToolsView;
- private HudSafeAreaView hudSafeAreaView;
- private SeekBar hudButtonSizeSeekBar;
- private SeekBar hudButtonOpacitySeekBar;
- private TextView hudButtonSizeText;
- private TextView hudButtonOpacityText;
-
- /** Guards so setting a slider's progress does not loop back through its own listener. */
- private boolean updatingHudButtonSize;
- private boolean updatingHudButtonOpacity;
-
- HudEditorController(Activity activity, Host host) {
- this.activity = activity;
- this.host = host;
- }
-
- /**
- * Binds the HUD tool views inside a freshly inflated overlay and wires their listeners.
- *
- *
Called from the menu's {@code setupViews}, after the overlay is inflated. The window
- * references are taken here and refreshed by {@link #updateWindow} on every later show, because
- * the menu re-creates its layout params each time.
- */
- void bind(View overlayView, WindowManager windowManager, WindowManager.LayoutParams wmParams) {
- this.overlayView = overlayView;
- updateWindow(windowManager, wmParams);
-
- hudEditorToolsView = overlayView.findViewById(R.id.hud_editor_tools);
- hudSafeAreaView = overlayView.findViewById(R.id.hud_safe_area);
- View dragHandle = overlayView.findViewById(R.id.hud_editor_drag_handle);
- if (dragHandle != null) bindDragHandle(dragHandle, hudEditorToolsView);
-
- hudButtonSizeSeekBar = overlayView.findViewById(R.id.seekbar_hud_button_size);
- hudButtonSizeText = overlayView.findViewById(R.id.text_hud_button_size);
- hudButtonOpacitySeekBar = overlayView.findViewById(R.id.seekbar_hud_button_opacity);
- hudButtonOpacityText = overlayView.findViewById(R.id.text_hud_button_opacity);
-
- if (hudButtonSizeSeekBar != null) {
- hudButtonSizeSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- if (!fromUser || updatingHudButtonSize) return;
- InbuiltOverlayManager manager = InbuiltOverlayManager.getInstance();
- if (manager != null) {
- manager.setSelectedHudEditorButtonSize(progress);
- }
- updateSizeText(progress);
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
- }
- });
- updateSizeControls(0);
- }
-
- if (hudButtonOpacitySeekBar != null) {
- hudButtonOpacitySeekBar.setOnSeekBarChangeListener(
- new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar seekBar, int progress,
- boolean fromUser) {
- if (!fromUser || updatingHudButtonOpacity) return;
- InbuiltOverlayManager manager = InbuiltOverlayManager.getInstance();
- if (manager != null) {
- manager.setSelectedHudEditorOpacity(progress);
- }
- updateOpacityText(progress);
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
- }
- });
- updateOpacityControls(0);
- }
-
- View btnHudSave = overlayView.findViewById(R.id.btn_hud_save);
- if (btnHudSave != null) {
- btnHudSave.setOnClickListener(v -> exit(menuContainer()));
- }
-
- View btnHudReset = overlayView.findViewById(R.id.btn_hud_reset);
- if (btnHudReset != null) {
- btnHudReset.setOnClickListener(v -> {
- InbuiltOverlayManager manager = InbuiltOverlayManager.getInstance();
- if (manager != null) manager.resetAllPositionsToCenter();
- });
- }
-
- View btnHudCancel = overlayView.findViewById(R.id.btn_hud_cancel);
- if (btnHudCancel != null) {
- btnHudCancel.setOnClickListener(v -> exit(menuContainer()));
- }
- }
-
- private View menuContainer() {
- return overlayView == null ? null : overlayView.findViewById(R.id.mod_menu_container);
- }
-
- /** Refreshes the window references after the menu re-created its layout params. */
- void updateWindow(WindowManager windowManager, WindowManager.LayoutParams wmParams) {
- this.windowManager = windowManager;
- this.wmParams = wmParams;
- }
-
- /** The HUD tool container, or null before {@link #bind}. The menu reads it to open the editor. */
- View toolsView() {
- return hudEditorToolsView;
- }
-
- /** True while the tool container is on screen. */
- boolean isOpen() {
- return hudEditorToolsView != null && hudEditorToolsView.getVisibility() == View.VISIBLE;
- }
-
- /** Opens the editor: hides the menu, floats the tool window and shows the safe-area guide. */
- void enter(View modMenuContainer) {
- host.onEditorNavigation();
-
- if (modMenuContainer != null) {
- modMenuContainer.setVisibility(View.GONE);
- }
- if (hudEditorToolsView != null) {
- hudEditorToolsView.setVisibility(View.VISIBLE);
- hudEditorToolsView.setAlpha(1f);
- hudEditorToolsView.setTranslationX(0f);
- }
- showSafeArea(true);
- if (overlayView != null) {
- overlayView.setBackgroundColor(android.graphics.Color.TRANSPARENT);
- overlayView.setClickable(false);
- }
- applyWindowMode(false);
- // Wait for the compact tool window to be measured before centering it.
- final View editorRoot = overlayView;
- if (editorRoot != null) {
- editorRoot.post(() -> {
- if (overlayView != editorRoot || !isOpen()) return;
- OverlayBounds.Position rightEdge = OverlayBounds.clampPosition(
- activity, editorRoot, Integer.MAX_VALUE, 0);
- moveTools(rightEdge.x / 2, 0);
- });
- }
- InbuiltOverlayManager overlayManager = InbuiltOverlayManager.getInstance();
- if (overlayManager != null) {
- overlayManager.setHudEditorSelectionListener(this::onSelectionChanged);
- overlayManager.setHudEditorMode(true);
- }
- }
-
- /** Closes the editor: restores the menu window and returns to the module list. */
- void exit(View modMenuContainer) {
- if (hudEditorToolsView != null) {
- hudEditorToolsView.setVisibility(View.GONE);
- }
- showSafeArea(false);
- if (overlayView != null) {
- overlayView.setClickable(true);
- }
- applyWindowMode(true);
- if (modMenuContainer != null) {
- modMenuContainer.setVisibility(View.VISIBLE);
- host.animateMenuEnter(modMenuContainer);
- }
- InbuiltOverlayManager overlayManager = InbuiltOverlayManager.getInstance();
- if (overlayManager != null) {
- overlayManager.setHudEditorMode(false);
- overlayManager.setHudEditorSelectionListener(null);
- }
- host.onEditorClosed();
- }
-
- /**
- * Switches the overlay window between the floating tool window and the full-screen menu.
- *
- * @param fullScreen true restores the menu's full-screen window, false shrinks it to the
- * wrap-content tool window the editor drags around
- */
- private void applyWindowMode(boolean fullScreen) {
- final int width = fullScreen
- ? WindowManager.LayoutParams.MATCH_PARENT
- : WindowManager.LayoutParams.WRAP_CONTENT;
- final int height = fullScreen
- ? WindowManager.LayoutParams.MATCH_PARENT
- : WindowManager.LayoutParams.WRAP_CONTENT;
- final int gravity = fullScreen ? Gravity.CENTER : (Gravity.TOP | Gravity.LEFT);
-
- if (wmParams != null && windowManager != null && overlayView != null) {
- wmParams.width = width;
- wmParams.height = height;
- wmParams.gravity = gravity;
- wmParams.x = 0;
- wmParams.y = 0;
- windowManager.updateViewLayout(overlayView, wmParams);
- } else if (overlayView != null
- && overlayView.getLayoutParams() instanceof FrameLayout.LayoutParams) {
- FrameLayout.LayoutParams params =
- (FrameLayout.LayoutParams) overlayView.getLayoutParams();
- params.width = width;
- params.height = height;
- params.gravity = gravity;
- params.leftMargin = 0;
- params.topMargin = 0;
- overlayView.setLayoutParams(params);
- }
- }
-
- /** The drag handle that moves the floating tool window. */
- private void bindDragHandle(View handle, View tools) {
- final int touchSlop = ViewConfiguration.get(activity).getScaledTouchSlop();
- handle.setOnTouchListener(new View.OnTouchListener() {
- private float downX;
- private float downY;
- private int startX;
- private int startY;
- private int pointerId = -1;
- private boolean dragging;
-
- @Override
- public boolean onTouch(View view, MotionEvent event) {
- if (overlayView == null || tools == null
- || tools.getVisibility() != View.VISIBLE) {
- return false;
- }
- switch (event.getActionMasked()) {
- case MotionEvent.ACTION_DOWN:
- pointerId = event.getPointerId(0);
- downX = event.getRawX();
- downY = event.getRawY();
- dragging = false;
- if (wmParams != null) {
- startX = wmParams.x;
- startY = wmParams.y;
- } else if (overlayView.getLayoutParams()
- instanceof FrameLayout.LayoutParams) {
- FrameLayout.LayoutParams params =
- (FrameLayout.LayoutParams) overlayView.getLayoutParams();
- startX = params.leftMargin;
- startY = params.topMargin;
- }
- view.getParent().requestDisallowInterceptTouchEvent(true);
- return true;
- case MotionEvent.ACTION_MOVE:
- if (pointerId < 0 || event.getPointerId(0) != pointerId) return true;
- float dx = event.getRawX() - downX;
- float dy = event.getRawY() - downY;
- if (Math.abs(dx) > touchSlop || Math.abs(dy) > touchSlop) dragging = true;
- if (dragging) {
- moveTools(startX + Math.round(dx), startY + Math.round(dy));
- }
- return true;
- case MotionEvent.ACTION_POINTER_UP:
- if (event.getPointerId(event.getActionIndex()) != pointerId) return true;
- pointerId = -1;
- view.getParent().requestDisallowInterceptTouchEvent(false);
- return true;
- case MotionEvent.ACTION_UP:
- if (pointerId >= 0 && !dragging) view.performClick();
- // Fall through: an up and a cancel both release the gesture.
- case MotionEvent.ACTION_CANCEL:
- pointerId = -1;
- dragging = false;
- view.getParent().requestDisallowInterceptTouchEvent(false);
- return true;
- default:
- return true;
- }
- }
- });
- }
-
- /** Moves the floating tool window to a clamped position. */
- private void moveTools(int x, int y) {
- if (overlayView == null) return;
- OverlayBounds.Position position = OverlayBounds.clampPosition(activity, overlayView, x, y);
- if (wmParams != null && windowManager != null) {
- wmParams.x = position.x;
- wmParams.y = position.y;
- windowManager.updateViewLayout(overlayView, wmParams);
- } else if (overlayView.getLayoutParams() instanceof FrameLayout.LayoutParams) {
- FrameLayout.LayoutParams params =
- (FrameLayout.LayoutParams) overlayView.getLayoutParams();
- params.leftMargin = position.x;
- params.topMargin = position.y;
- overlayView.setLayoutParams(params);
- }
- }
-
- /**
- * Shows or hides the safe-area frame.
- *
- *
The frame is measured against the window it will cover, so it is populated after the first
- * layout pass rather than at inflation, when the window size is still unknown.
- */
- private void showSafeArea(boolean show) {
- if (hudSafeAreaView == null) return;
- if (!show) {
- hudSafeAreaView.setVisibility(View.GONE);
- return;
- }
- hudSafeAreaView.setVisibility(View.VISIBLE);
- hudSafeAreaView.post(() -> {
- if (hudSafeAreaView == null || overlayView == null) return;
- int width = overlayView.getWidth();
- int height = overlayView.getHeight();
- if (width <= 0 || height <= 0) return;
- OverlaySafeArea.Rect safe = OverlaySafeArea.forView(overlayView, width, height);
- int gridPx = Math.round(OverlaySafeArea.DEFAULT_GRID_DP
- * activity.getResources().getDisplayMetrics().density);
- hudSafeAreaView.setGeometry(safe, OverlaySafeArea.cutoutBounds(overlayView), gridPx);
- });
- }
-
- /** A selection arrived from the overlay manager: show its size and opacity. */
- private void onSelectionChanged(int sizeDp, int opacity) {
- updateSizeControls(sizeDp);
- updateOpacityControls(opacity);
- }
-
- private void updateSizeControls(int sizeDp) {
- if (hudButtonSizeSeekBar == null) return;
- boolean hasSelection = sizeDp > 0;
- hudButtonSizeSeekBar.setEnabled(hasSelection);
- if (hudButtonOpacitySeekBar != null) {
- hudButtonOpacitySeekBar.setEnabled(hasSelection);
- }
- if (!hasSelection) {
- updateSizeText(0);
- updateOpacityText(0);
- return;
- }
- updatingHudButtonSize = true;
- hudButtonSizeSeekBar.setProgress(sizeDp);
- updatingHudButtonSize = false;
- updateSizeText(sizeDp);
- }
-
- private void updateOpacityControls(int opacity) {
- if (hudButtonOpacitySeekBar == null) return;
- if (opacity <= 0) {
- updateOpacityText(0);
- return;
- }
- updatingHudButtonOpacity = true;
- hudButtonOpacitySeekBar.setProgress(opacity);
- updatingHudButtonOpacity = false;
- updateOpacityText(opacity);
- }
-
- private void updateOpacityText(int opacity) {
- if (hudButtonOpacityText == null) return;
- if (opacity <= 0) {
- hudButtonOpacityText.setText(activity.getString(R.string.overlay_button_opacity));
- } else {
- hudButtonOpacityText.setText(
- activity.getString(R.string.overlay_button_opacity_value, opacity));
- }
- }
-
- private void updateSizeText(int size) {
- if (hudButtonSizeText == null) return;
- if (size <= 0) {
- hudButtonSizeText.setText(activity.getString(R.string.overlay_button_size));
- } else {
- hudButtonSizeText.setText(
- activity.getString(R.string.overlay_button_size_value, size));
- }
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/HudSafeAreaView.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/HudSafeAreaView.java
deleted file mode 100644
index bd608467..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/HudSafeAreaView.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.Rect;
-import android.graphics.RectF;
-import android.view.View;
-
-import java.util.List;
-
-/**
- * Draws the HUD editor's safe-area frame, cutout and grid.
- *
- *
Without it the editor lets a widget be dropped under a notch or the gesture bar, where it is
- * clipped and the user has no way to know why. The frame shows exactly the box the editor clamps
- * into, so "why will it not go further left" is answered on screen rather than by trial.
- *
- *
It is a single overlay drawn over the editor, not a view per widget: one {@code onDraw}, no
- * per-frame allocation (the paints and the grid {@link android.graphics.Path} are built once), and
- * it is created with {@code FLAG_NOT_TOUCHABLE} by its host so it can never eat a drag.
- */
-public final class HudSafeAreaView extends View {
-
- private final Paint framePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint gridPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint cutoutPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final RectF frameRect = new RectF();
-
- private OverlaySafeArea.Rect safe;
- private List cutouts = java.util.Collections.emptyList();
- private int gridPx;
-
- public HudSafeAreaView(Context context) {
- this(context, null);
- }
-
- public HudSafeAreaView(Context context, android.util.AttributeSet attrs) {
- super(context, attrs);
- framePaint.setStyle(Paint.Style.STROKE);
- framePaint.setStrokeWidth(context.getResources().getDisplayMetrics().density * 1.5f);
- framePaint.setColor(0x66FFFFFF);
- gridPaint.setStyle(Paint.Style.STROKE);
- gridPaint.setStrokeWidth(1f);
- gridPaint.setColor(0x22FFFFFF);
- cutoutPaint.setStyle(Paint.Style.FILL);
- cutoutPaint.setColor(0xCC000000);
- setWillNotDraw(false);
- }
-
- /** Updates the frame; a null safe rect hides everything. */
- public void setGeometry(OverlaySafeArea.Rect safe, List cutouts, int gridPx) {
- this.safe = safe;
- this.cutouts = cutouts == null ? java.util.Collections.emptyList() : cutouts;
- this.gridPx = gridPx;
- invalidate();
- }
-
- @Override
- protected void onDraw(Canvas canvas) {
- super.onDraw(canvas);
- OverlaySafeArea.Rect area = safe;
- if (area == null || area.width() <= 0 || area.height() <= 0) return;
-
- if (gridPx > 0) {
- for (int x = area.left + gridPx; x < area.right; x += gridPx) {
- canvas.drawLine(x, area.top, x, area.bottom, gridPaint);
- }
- for (int y = area.top + gridPx; y < area.bottom; y += gridPx) {
- canvas.drawLine(area.left, y, area.right, y, gridPaint);
- }
- }
-
- frameRect.set(area.left, area.top, area.right, area.bottom);
- canvas.drawRect(frameRect, framePaint);
-
- for (Rect cutout : cutouts) {
- if (cutout == null) continue;
- canvas.drawRect(cutout, cutoutPaint);
- }
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java
new file mode 100644
index 00000000..e7aca1d6
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java
@@ -0,0 +1,402 @@
+package org.chimeramc.client.launcher.core.mods.inbuilt.overlay;
+
+import org.chimeramc.client.util.AppLog;
+
+import android.app.Activity;
+import android.view.MotionEvent;
+
+import org.chimeramc.client.R;
+import org.chimeramc.client.core.mods.inbuilt.ExternalModuleProvider;
+import org.chimeramc.client.core.mods.inbuilt.InbuiltModuleProvider;
+import org.chimeramc.client.core.mods.inbuilt.UnifiedMod;
+import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
+import org.chimeramc.client.core.mods.inbuilt.model.ModAvailability;
+import org.chimeramc.client.core.mods.inbuilt.overlay.ModNotificationManager;
+import org.chimeramc.client.preloader.PreloaderMenu;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Drives the native in-game Mod Menu from the launcher side.
+ *
+ * The menu itself is drawn natively (Dear ImGui, on the game's GL context — see {@link
+ * PreloaderMenu}); this class is the bridge. It builds the module catalogue the menu shows, forwards
+ * the game view's touch events into it, and applies the interactions the menu raises back onto the
+ * real modules. That keeps the native side free of Android types and keeps this side testable.
+ *
+ *
The catalogue and the drained events cross as JSON. {@link #buildCatalogueJson} and {@link
+ * #applyEvent} are pure with respect to the native library, so the shape of both documents is
+ * pinned by unit tests rather than discovered on a device.
+ *
+ *
Honesty. A module that cannot run in this build is sent with its reason and the menu
+ * badges it; the menu never shows a toggle that would do nothing. The module list is the same one
+ * the launcher shows, from the same providers, so a module cannot exist in one and be missing from
+ * the other.
+ */
+public final class InGameMenuController {
+
+ private static final String TAG = "InGameMenuController";
+
+ private static volatile InGameMenuController instance;
+
+ private final Activity activity;
+ private final ModNotificationManager notifications;
+ private boolean installed;
+ /** The catalogue revision last sent, so an unchanged list is not rebuilt every tick. */
+ private int lastCatalogueHash;
+
+ private InGameMenuController(Activity activity) {
+ this.activity = activity;
+ this.notifications = new ModNotificationManager(activity);
+ }
+
+ /** The controller for an activity, or null before one is installed. */
+ public static InGameMenuController getInstance() {
+ return instance;
+ }
+
+ /** Installs the controller for a game session. Idempotent per activity. */
+ public static InGameMenuController install(Activity activity) {
+ if (activity == null) return null;
+ InGameMenuController existing = instance;
+ if (existing != null && existing.activity == activity) {
+ existing.ensureNativeInstalled();
+ return existing;
+ }
+ InGameMenuController controller = new InGameMenuController(activity);
+ instance = controller;
+ controller.ensureNativeInstalled();
+ controller.registerFonts();
+ controller.pushCatalogue();
+ return controller;
+ }
+
+ /** Drops the controller when the game session ends. */
+ public static void release(Activity activity) {
+ InGameMenuController current = instance;
+ if (current == null) return;
+ if (activity != null && current.activity != activity) return;
+ PreloaderMenu.setOpen(false);
+ instance = null;
+ }
+
+ private void ensureNativeInstalled() {
+ if (installed) return;
+ PreloaderMenu.install();
+ installed = true;
+ }
+
+ /** True when the native menu's ImGui context is up in the game process. */
+ public boolean isMenuLive() {
+ return PreloaderMenu.isMenuLive();
+ }
+
+ /** Whether the native menu is currently open. */
+ public boolean isMenuOpen() {
+ return PreloaderMenu.isOpen();
+ }
+
+ /** Opens the native menu, refreshing the catalogue first. */
+ public void show() {
+ if (!PreloaderMenu.isMenuLive()) {
+ // The present hook has not installed (or this build has none). The menu cannot draw, so
+ // it must not open onto a black screen; say so once instead.
+ AppLog.warnOnce(TAG, "show", "The in-game menu is not live on this build", null);
+ return;
+ }
+ pushCatalogue();
+ PreloaderMenu.setOpen(true);
+ }
+
+ /** Closes the native menu. */
+ public void hide() {
+ PreloaderMenu.setOpen(false);
+ }
+
+ /** Opens the menu when it is closed, closes it when it is open. */
+ public void toggle() {
+ if (isMenuOpen()) {
+ hide();
+ } else {
+ show();
+ }
+ }
+
+ /**
+ * Forwards a touch event to the native menu.
+ *
+ * @return true when the menu is open and therefore consuming the touch, so the game must not
+ * also see it.
+ */
+ public boolean handleTouch(MotionEvent event) {
+ if (event == null || !isMenuOpen()) return false;
+ final float x = event.getX();
+ final float y = event.getY();
+ // The game view's size is the surface the menu draws into, so the coordinate maps 1:1. The
+ // view size is passed so the native side can scale if it ever differs (a letterboxed surface).
+ PreloaderMenu.pushTouch(event.getActionMasked(), x, y, 0f, 0f);
+ return true;
+ }
+
+ /** Forwards a back/close request (the back button or a controller's B). */
+ public boolean handleBack() {
+ if (!isMenuOpen()) return false;
+ PreloaderMenu.pushBack();
+ return true;
+ }
+
+ /**
+ * Drains the interactions the menu raised and applies them to the real modules.
+ *
+ *
Called from the per-frame tick, on the main thread, so the module changes land on the same
+ * thread the launcher's own toggles use.
+ */
+ public void drainAndApply() {
+ if (!installed) return;
+ final String json = PreloaderMenu.drainEvents();
+ if (json == null || json.isEmpty() || "[]".equals(json)) return;
+ boolean changed = false;
+ try {
+ final JSONArray events = new JSONArray(json);
+ for (int i = 0; i < events.length(); i++) {
+ changed |= applyEvent(events.getJSONObject(i));
+ }
+ } catch (Exception e) {
+ AppLog.warnOnce(TAG, "drain", "Ignored a malformed menu event batch", e);
+ return;
+ }
+ if (changed) pushCatalogue();
+ }
+
+ /**
+ * Applies one menu event. Returns true when the module list changed and must be re-sent.
+ *
+ *
Package-private and free of Android view code so the mapping from a menu event to a module
+ * change is unit-tested.
+ */
+ boolean applyEvent(JSONObject event) {
+ final String kind = event.optString("kind", "");
+ final String key = event.optString("key", "");
+ final String value = event.optString("value", "");
+ if (key.isEmpty()) return false;
+ final UnifiedMod mod = findMod(key);
+ if (mod == null) return false;
+ switch (kind) {
+ case "toggle_module":
+ if (!ModAvailability.isInteractive(mod.getId())) return false;
+ mod.applyEnabled("true".equals(value));
+ InbuiltModManager manager = InbuiltModManager.getInstance(activity);
+ if (manager != null && mod.isEnabled() && manager.isNotificationsEnabled()) {
+ notifications.show(mod.getName(), mod.getStableKey());
+ }
+ return true;
+ case "set_config":
+ return applyConfig(mod, value);
+ case "toggle_favorite":
+ return toggleFavorite(mod);
+ case "close":
+ return false;
+ default:
+ return false;
+ }
+ }
+
+ private boolean applyConfig(UnifiedMod mod, String keyValue) {
+ final int sep = keyValue.indexOf('=');
+ if (sep <= 0) return false;
+ final String configKey = keyValue.substring(0, sep);
+ final String configValue = keyValue.substring(sep + 1);
+ mod.updateConfig(configKey, configValue);
+ return false;
+ }
+
+ private boolean toggleFavorite(UnifiedMod mod) {
+ InbuiltModManager manager = InbuiltModManager.getInstance(activity);
+ if (manager == null) return false;
+ final boolean nowFavorite = !manager.getFavoriteModKeys().contains(mod.getStableKey());
+ manager.setModFavorite(mod.getStableKey(), nowFavorite);
+ return true;
+ }
+
+ private UnifiedMod findMod(String stableKey) {
+ for (UnifiedMod mod : loadMods()) {
+ if (mod.getStableKey().equals(stableKey)) return mod;
+ }
+ return null;
+ }
+
+ private List loadMods() {
+ List mods = new ArrayList<>(InbuiltModuleProvider.load(activity));
+ mods.addAll(ExternalModuleProvider.load(activity));
+ return mods;
+ }
+
+ /** Rebuilds and sends the catalogue, unless it is unchanged since the last send. */
+ public void pushCatalogue() {
+ if (!installed) return;
+ final List mods = loadMods();
+ final Set favorites = favoriteKeys();
+ final String json = buildCatalogueJson(mods, favorites, emptyReason(), this::reasonMessage);
+ final int hash = json.hashCode();
+ if (hash == lastCatalogueHash) return;
+ lastCatalogueHash = hash;
+ PreloaderMenu.setCatalogue(json);
+ }
+
+ private Set favoriteKeys() {
+ InbuiltModManager manager = InbuiltModManager.getInstance(activity);
+ return manager == null ? java.util.Collections.emptySet() : manager.getFavoriteModKeys();
+ }
+
+ private String emptyReason() {
+ return activity.getString(R.string.mod_menu_no_mods);
+ }
+
+ /**
+ * The wording for a module's unavailable reason.
+ *
+ * Every reason has its own message: a module waiting for an engine hook, one waiting for a
+ * game feed this build cannot read, one a server decides, and one waiting on a backend feature
+ * are four different situations with four different answers. A generic "unavailable" tells the
+ * player none of them.
+ */
+ private String reasonMessage(String reasonId) {
+ int res = reasonStringRes(reasonId);
+ return res == 0 ? "" : activity.getString(res);
+ }
+
+ /** The string resource that words a reason id, or 0 when the id is unknown. */
+ static int reasonStringRes(String reasonId) {
+ if (ModAvailability.REASON_SERVER_DECIDED.equals(reasonId)) {
+ return R.string.mod_unavailable_reason_server;
+ }
+ if (ModAvailability.REASON_NO_BACKEND.equals(reasonId)) {
+ return R.string.mod_unavailable_reason_backend;
+ }
+ if (ModAvailability.REASON_NO_HOOK.equals(reasonId)) {
+ return R.string.mod_unavailable_reason_hook;
+ }
+ if (ModAvailability.REASON_NO_GAME_DATA.equals(reasonId)) {
+ return R.string.mod_unavailable_reason;
+ }
+ return 0;
+ }
+
+ /**
+ * Builds the catalogue document the native menu reads.
+ *
+ *
Static and free of Android types other than the strings it is handed, so the shape is
+ * unit-tested against the exact fields the native parser reads. The reason is already worded by
+ * {@code messageFor}, so the native menu never shows a raw reason id.
+ */
+ static String buildCatalogueJson(List mods, Set favorites,
+ String emptyReason,
+ java.util.function.Function messageFor) {
+ try {
+ JSONArray array = new JSONArray();
+ for (UnifiedMod mod : mods) {
+ array.put(moduleJson(mod, favorites, messageFor));
+ }
+ JSONObject root = new JSONObject();
+ root.put("modules", array);
+ root.put("empty_reason", emptyReason == null ? "" : emptyReason);
+ return root.toString();
+ } catch (Exception e) {
+ return "{\"modules\":[],\"empty_reason\":\"\"}";
+ }
+ }
+
+ private static JSONObject moduleJson(UnifiedMod mod, Set favorites,
+ java.util.function.Function messageFor)
+ throws Exception {
+ JSONObject obj = new JSONObject();
+ obj.put("stable_key", mod.getStableKey());
+ obj.put("name", mod.getName());
+ obj.put("description", mod.getDescription());
+ obj.put("group", mod.getGroupName());
+ obj.put("enabled", mod.isEnabled());
+ obj.put("favorite", favorites != null && favorites.contains(mod.getStableKey()));
+ obj.put("has_config", mod.hasConfig());
+ String reason = ModAvailability.unavailableReason(mod.getId());
+ String message = reason == null ? "" : messageFor.apply(reason);
+ obj.put("unavailable", message == null ? "" : message);
+ obj.put("configs", configsJson(mod));
+ return obj;
+ }
+
+ private static JSONArray configsJson(UnifiedMod mod) throws Exception {
+ JSONArray configs = new JSONArray();
+ for (UnifiedMod.ConfigEntry entry : mod.getConfigEntries()) {
+ JSONObject cfg = new JSONObject();
+ cfg.put("key", entry.key);
+ cfg.put("label", entry.displayName);
+ cfg.put("value", entry.currentValue == null ? "" : entry.currentValue);
+ switch (entry.type) {
+ case TOGGLE:
+ cfg.put("kind", "toggle");
+ cfg.put("float_value", "true".equalsIgnoreCase(entry.currentValue) ? 1.0 : 0.0);
+ break;
+ case SLIDER_INT:
+ case SLIDER_FLOAT:
+ cfg.put("kind", "slider");
+ cfg.put("float_value", parseFloat(entry.currentValue, 0f));
+ cfg.put("float_min", parseFloat(entry.minValue, 0f));
+ cfg.put("float_max", parseFloat(entry.maxValue, 1f));
+ break;
+ default:
+ // A colour, a keybind or a text field has no in-game editor yet; it is shown
+ // read-only with its value rather than dropped from the panel.
+ cfg.put("kind", "readonly");
+ break;
+ }
+ configs.put(cfg);
+ }
+ return configs;
+ }
+
+ private static float parseFloat(String value, float fallback) {
+ if (value == null || value.isEmpty()) return fallback;
+ try {
+ return Float.parseFloat(value.trim());
+ } catch (NumberFormatException e) {
+ return fallback;
+ }
+ }
+
+ private void registerFonts() {
+ // The pack's roles. Inter for body text, Poppins for titles; the launcher already ships both.
+ registerFont(0, R.font.inter);
+ registerFont(1, R.font.inter);
+ registerFont(2, R.font.inter);
+ registerFont(3, R.font.poppins);
+ registerFont(4, R.font.poppins);
+ }
+
+ private void registerFont(int role, int fontRes) {
+ try {
+ byte[] bytes = readResource(fontRes);
+ if (bytes != null) PreloaderMenu.registerFont(role, bytes);
+ } catch (Exception e) {
+ AppLog.warnOnce(TAG, "font", "A menu font could not be read; the default is used", e);
+ }
+ }
+
+ private byte[] readResource(int resId) {
+ try (java.io.InputStream in = activity.getResources().openRawResource(resId)) {
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
+ byte[] buffer = new byte[8192];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return out.toByteArray();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InbuiltOverlayManager.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InbuiltOverlayManager.java
index d1e9d823..7b01b246 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InbuiltOverlayManager.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InbuiltOverlayManager.java
@@ -18,6 +18,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController;
public class InbuiltOverlayManager {
public interface HudEditorSelectionListener {
@@ -1242,19 +1243,9 @@ public boolean handleKeyEvent(int keyCode, int action, int rawKeyCode) {
boolean matchesBind = InbuiltModManager.matchesModMenuBind(
manager.getModMenuKeybind(), manager.getModMenuControllerBind(),
keyCode, rawKeyCode);
- if (matchesBind) {
- boolean hold = manager.isRadialWheelHoldToOpen();
- org.chimeramc.client.core.mods.inbuilt.overlay.RadialMenuOverlay wheel =
- modMenuButton.ensureRadialMenuOverlay();
- if (action == android.view.KeyEvent.ACTION_DOWN) {
- if (wheel != null && wheel.onBindPress(hold)) return true;
- if (!hold) {
- modMenuButton.toggleMenuFromBind();
- return true;
- }
- } else if (action == android.view.KeyEvent.ACTION_UP && hold) {
- if (wheel != null && wheel.onBindRelease(true)) return true;
- }
+ if (matchesBind && action == android.view.KeyEvent.ACTION_DOWN) {
+ modMenuButton.toggleMenuFromBind();
+ return true;
}
}
@@ -1311,33 +1302,20 @@ public boolean handleKeyEvent(int keyCode, int action, int rawKeyCode) {
* would otherwise be consumed by the game first.
*/
public boolean handleMenuControllerKey(int rawKeyCode, int action) {
- if (modMenuButton == null) return false;
- // The radial wheel owns controller input while it is open, so the stick drives the cursor
- // and no button reaches the game (no camera spin, no attacks).
- org.chimeramc.client.core.mods.inbuilt.overlay.RadialMenuOverlay wheel =
- modMenuButton.getRadialMenuOverlay();
- if (wheel != null && wheel.isShowing()) {
- return wheel.handleControllerKey(rawKeyCode,
- action == android.view.KeyEvent.ACTION_DOWN);
- }
- // There is one Mod Menu now (U4); it owns controller input while it is open.
- ModMenuOverlay menu = modMenuButton.getMenuOverlay();
- if (menu == null || !menu.isShowing()) return false;
- return menu.handleControllerKey(rawKeyCode,
- action == android.view.KeyEvent.ACTION_DOWN);
+ // The menu is drawn natively, so a controller key is translated to a menu action here and
+ // pushed into the native input queue rather than routed to an Android view. Only the back
+ // button is handled for now; the native menu's own nav flags cover the rest once it is open.
+ if (action != android.view.KeyEvent.ACTION_DOWN) return false;
+ if (rawKeyCode != android.view.KeyEvent.KEYCODE_BACK) return false;
+ InGameMenuController menu = InGameMenuController.getInstance();
+ return menu != null && menu.handleBack();
}
/** Hands analogue stick movement to the open Mod Menu, so the stick can move the selection. */
public boolean handleMenuControllerMotion(android.view.MotionEvent event) {
- if (modMenuButton == null) return false;
- // The radial wheel takes the stick first while it is open.
- org.chimeramc.client.core.mods.inbuilt.overlay.RadialMenuOverlay wheel =
- modMenuButton.getRadialMenuOverlay();
- if (wheel != null && wheel.isShowing() && wheel.handleControllerMotion(event)) {
- return true;
- }
- ModMenuOverlay menu = modMenuButton.getMenuOverlay();
- return menu != null && menu.isShowing() && menu.handleControllerMotion(event);
+ // The native menu navigates with touch and the keyboard nav flags; stick-driven selection is
+ // not wired yet, so this reports false rather than swallowing the movement.
+ return false;
}
public boolean handleScrollEvent(float scrollDelta) {
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModConfigView.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModConfigView.java
deleted file mode 100644
index d7223227..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModConfigView.java
+++ /dev/null
@@ -1,488 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.content.Context;
-import android.content.res.ColorStateList;
-import android.graphics.Color;
-import android.view.KeyEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.LinearLayout;
-import android.widget.RadioButton;
-import android.widget.RadioGroup;
-import android.widget.SeekBar;
-import android.widget.Switch;
-import android.widget.ScrollView;
-import android.widget.TextView;
-
-import androidx.appcompat.app.AlertDialog;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.mods.inbuilt.UnifiedMod;
-
-import java.util.List;
-import org.chimeramc.client.util.HexColor;
-
-public class ModConfigView {
-
- public static void render(Context context, ViewGroup container, UnifiedMod mod, Runnable onConfigChanged) {
- render(context, container, mod, false, onConfigChanged);
- }
-
- public static void render(Context context, ViewGroup container, UnifiedMod mod, boolean compact, Runnable onConfigChanged) {
- RuntimeConfigView.stop(container);
- container.removeAllViews();
- if (RuntimeConfigView.render(context, container, mod, compact, onConfigChanged)) return;
-
- float density = context.getResources().getDisplayMetrics().density;
- int accent = 0xFF0F5BE4;
- ScrollView scroll = new ScrollView(context);
- scroll.setFillViewport(true);
- LinearLayout content = new LinearLayout(context);
- content.setOrientation(LinearLayout.VERTICAL);
- content.setPadding((int)(16 * density), (int)(16 * density), (int)(16 * density), (int)(16 * density));
- scroll.addView(content, new ScrollView.LayoutParams(
- ScrollView.LayoutParams.MATCH_PARENT, ScrollView.LayoutParams.WRAP_CONTENT));
- container.addView(scroll, new ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
- renderConfigEntries(context, content, mod, accent, density, onConfigChanged);
- }
-
- private static void renderConfigEntries(Context context, ViewGroup container, UnifiedMod mod, int accent, float density, Runnable onConfigChanged) {
- List configs = mod.getConfigEntries();
-
- java.util.Map> configViews = new java.util.HashMap<>();
- Runnable applyDependencies = () -> {
- for (UnifiedMod.ConfigEntry cfg : configs) {
- if (cfg.dependsOn != null && !cfg.dependsOn.isEmpty() && configViews.containsKey(cfg.key)) {
- boolean isParentEnabled = false;
- for (UnifiedMod.ConfigEntry parentCfg : configs) {
- if (parentCfg.key.equals(cfg.dependsOn)) {
- isParentEnabled = "true".equalsIgnoreCase(parentCfg.currentValue) || "1".equals(parentCfg.currentValue);
- break;
- }
- }
- for (View v : configViews.get(cfg.key)) {
- v.setVisibility(isParentEnabled ? View.VISIBLE : View.GONE);
- }
- }
- }
- };
- Runnable wrappedOnConfigChanged = () -> {
- applyDependencies.run();
- onConfigChanged.run();
- };
-
- for (UnifiedMod.ConfigEntry cfg : configs) {
- int startIndex = container.getChildCount();
- switch (cfg.type) {
- case TOGGLE:
- boolean checked = "true".equalsIgnoreCase(cfg.currentValue) || "1".equals(cfg.currentValue);
- addToggle(context, container, cfg.displayName, checked, accent, density, isChecked -> {
- mod.updateConfig(cfg, isChecked ? "true" : "false");
- wrappedOnConfigChanged.run();
- });
- break;
- case SLIDER_INT: {
- int min = parseIntSafe(cfg.minValue, 0);
- int max = parseIntSafe(cfg.maxValue, 100);
- int cur = parseIntSafe(cfg.currentValue, parseIntSafe(cfg.defaultValue, min));
- addSlider(context, container, cfg.displayName, cur, min, max, accent, density, progress -> {
- mod.updateConfig(cfg, String.valueOf(progress));
- wrappedOnConfigChanged.run();
- });
- break;
- }
- case SLIDER_FLOAT: {
- float fMin = parseFloatSafe(cfg.minValue, 0f);
- float fMax = parseFloatSafe(cfg.maxValue, 1f);
- float fCur = parseFloatSafe(cfg.currentValue, parseFloatSafe(cfg.defaultValue, fMin));
- int steps = 100;
- int curProgress = (int)((fCur - fMin) / (fMax - fMin) * steps);
-
- LinearLayout row = createRow(context, container, density);
- TextView label = createLabel(context, cfg.displayName, density);
- TextView valText = createValueText(context, String.format("%.2f", fCur), accent, density);
- row.addView(label);
- row.addView(valText);
- container.addView(row);
-
- SeekBar seekBar = new SeekBar(context);
- seekBar.setMin(0);
- seekBar.setMax(steps);
- seekBar.setProgress(Math.max(0, Math.min(steps, curProgress)));
- applyAccentToSeekBar(seekBar, accent);
-
- seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar sb, int progress, boolean fromUser) {
- if (fromUser) {
- float val = fMin + (fMax - fMin) * progress / (float) steps;
- valText.setText(String.format("%.2f", val));
- mod.updateConfig(cfg, String.valueOf(val));
- wrappedOnConfigChanged.run();
- }
- }
- @Override public void onStartTrackingTouch(SeekBar sb) {}
- @Override public void onStopTrackingTouch(SeekBar sb) {}
- });
- addWithMargin(container, seekBar, density);
- break;
- }
- case RADIO: {
- LinearLayout row = createRow(context, container, density);
- row.addView(createLabel(context, cfg.displayName, density));
- container.addView(row);
-
- RadioGroup radioGroup = new RadioGroup(context);
- radioGroup.setOrientation(LinearLayout.VERTICAL);
-
- String[] options = cfg.minValue != null ? cfg.minValue.split(",") : new String[0];
- int selectedIndex = parseIntSafe(cfg.currentValue, parseIntSafe(cfg.defaultValue, 0));
- final java.util.Map optionIds = new java.util.HashMap<>();
-
- for (int i = 0; i < options.length; i++) {
- RadioButton rb = new RadioButton(context);
- int optionId = View.generateViewId();
- rb.setId(optionId);
- rb.setText(options[i]);
- rb.setTextColor(0xFFF1F4F6);
- rb.setTextSize(14);
- int[][] states = {{android.R.attr.state_checked}, {}};
- rb.setButtonTintList(new ColorStateList(states, new int[]{accent, 0xFFA8B0B8}));
- optionIds.put(optionId, i);
-
- LinearLayout.LayoutParams rbParams = new LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
- rb.setLayoutParams(rbParams);
- radioGroup.addView(rb);
- if (i == selectedIndex) rb.setChecked(true);
- }
-
- radioGroup.setOnCheckedChangeListener((group, checkedId) -> {
- Integer currentIndex = optionIds.get(checkedId);
- if (currentIndex == null) return;
- String newValue = String.valueOf(currentIndex);
- if (newValue.equals(cfg.currentValue)) return;
-
- mod.updateConfig(cfg, newValue);
- wrappedOnConfigChanged.run();
- });
-
- LinearLayout.LayoutParams rgParams = new LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
- rgParams.leftMargin = (int)(4 * density);
- rgParams.topMargin = (int)(4 * density);
- container.addView(radioGroup, rgParams);
- break;
- }
- case COLOR: {
- LinearLayout headerRow = new LinearLayout(context);
- headerRow.setOrientation(LinearLayout.HORIZONTAL);
- headerRow.setGravity(android.view.Gravity.CENTER_VERTICAL);
- LinearLayout.LayoutParams headerParams = new LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
- headerParams.topMargin = (int)(12 * density);
- headerRow.setLayoutParams(headerParams);
-
- TextView label = createLabel(context, cfg.displayName, density);
- headerRow.addView(label);
-
- View colorPreview = new View(context);
- LinearLayout.LayoutParams previewParams = new LinearLayout.LayoutParams(
- (int)(40 * density), (int)(20 * density));
-
- String currentHex = cfg.currentValue.isEmpty() ? cfg.defaultValue : cfg.currentValue;
- int initialColor = HexColor.parseOrWhite(currentHex);
-
- android.graphics.drawable.GradientDrawable gd = new android.graphics.drawable.GradientDrawable();
- gd.setColor(initialColor);
- gd.setCornerRadius(4 * density);
- gd.setStroke((int)(1 * density), 0xFF343A40);
- colorPreview.setBackground(gd);
- headerRow.addView(colorPreview, previewParams);
- container.addView(headerRow);
-
- LinearLayout slidersContainer = new LinearLayout(context);
- slidersContainer.setOrientation(LinearLayout.VERTICAL);
- slidersContainer.setVisibility(View.GONE);
- slidersContainer.setPadding((int)(12 * density), (int)(8 * density), 0, (int)(8 * density));
-
- final int[] currentColor = {initialColor};
-
- ValueChangeListener onSliderChange = val -> {
- gd.setColor(currentColor[0]);
- colorPreview.setBackground(gd);
- mod.updateConfig(cfg, String.format("#%08X", currentColor[0]));
- wrappedOnConfigChanged.run();
- };
-
- int a = Color.alpha(initialColor);
- int r = Color.red(initialColor);
- int g = Color.green(initialColor);
- int b = Color.blue(initialColor);
-
- SeekBar alphaSlider = addColorSliderInline(context, slidersContainer, context.getString(R.string.mod_config_color_alpha_short), a, 0xFFFFFFFF, density, progress -> {
- currentColor[0] = Color.argb(progress, Color.red(currentColor[0]), Color.green(currentColor[0]), Color.blue(currentColor[0]));
- onSliderChange.onValueChanged(progress);
- });
- SeekBar redSlider = addColorSliderInline(context, slidersContainer, context.getString(R.string.mod_config_color_red_short), r, 0xFFFF4444, density, progress -> {
- currentColor[0] = Color.argb(Color.alpha(currentColor[0]), progress, Color.green(currentColor[0]), Color.blue(currentColor[0]));
- onSliderChange.onValueChanged(progress);
- });
- SeekBar greenSlider = addColorSliderInline(context, slidersContainer, context.getString(R.string.mod_config_color_green_short), g, 0xFF44FF44, density, progress -> {
- currentColor[0] = Color.argb(Color.alpha(currentColor[0]), Color.red(currentColor[0]), progress, Color.blue(currentColor[0]));
- onSliderChange.onValueChanged(progress);
- });
- SeekBar blueSlider = addColorSliderInline(context, slidersContainer, context.getString(R.string.mod_config_color_blue_short), b, 0xFF4444FF, density, progress -> {
- currentColor[0] = Color.argb(Color.alpha(currentColor[0]), Color.red(currentColor[0]), Color.green(currentColor[0]), progress);
- onSliderChange.onValueChanged(progress);
- });
-
- container.addView(slidersContainer, new LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
-
- headerRow.setOnClickListener(v -> {
- boolean isVisible = slidersContainer.getVisibility() == View.VISIBLE;
- slidersContainer.setVisibility(isVisible ? View.GONE : View.VISIBLE);
- });
- break;
- }
- case KEYBIND: {
- int currentKey = parseIntSafe(cfg.currentValue, parseIntSafe(cfg.defaultValue, 0));
- addKeybindCapture(context, container, cfg.displayName, currentKey, accent, density, keyCode -> {
- mod.updateConfig(cfg, String.valueOf(keyCode));
- wrappedOnConfigChanged.run();
- });
- break;
- }
- case TEXT: {
- LinearLayout row = createRow(context, container, density);
- TextView label = createLabel(context, cfg.displayName, density);
- android.widget.EditText editText = new android.widget.EditText(context);
- editText.setText(cfg.currentValue.isEmpty() ? cfg.defaultValue : cfg.currentValue);
- editText.setTextColor(0xFFF1F4F6);
- editText.setTextSize(14);
- editText.setBackgroundTintList(ColorStateList.valueOf(accent));
- editText.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1));
- editText.addTextChangedListener(new android.text.TextWatcher() {
- @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
- @Override public void onTextChanged(CharSequence s, int start, int before, int count) {}
- @Override public void afterTextChanged(android.text.Editable s) {
- mod.updateConfig(cfg, s.toString());
- wrappedOnConfigChanged.run();
- }
- });
- row.addView(label);
- row.addView(editText);
- container.addView(row);
- break;
- }
- case BUTTON: {
- LinearLayout row = createRow(context, container, density);
- Button btn = new Button(context);
- btn.setText(cfg.displayName);
- btn.setBackgroundTintList(ColorStateList.valueOf(0xFF24282C));
- btn.setTextColor(accent);
- btn.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
- btn.setOnClickListener(v -> {
- mod.updateConfig(cfg, "true");
- wrappedOnConfigChanged.run();
- });
- row.addView(btn);
- container.addView(row);
- break;
- }
- }
-
- int endIndex = container.getChildCount();
- java.util.List viewsAdded = new java.util.ArrayList<>();
- for (int i = startIndex; i < endIndex; i++) {
- viewsAdded.add(container.getChildAt(i));
- }
- configViews.put(cfg.key, viewsAdded);
- }
- applyDependencies.run();
- }
-
- private static void addSlider(Context context, ViewGroup container, String labelText, int cur, int min, int max, int accent, float density, ValueChangeListener listener) {
- LinearLayout row = createRow(context, container, density);
- TextView label = createLabel(context, labelText, density);
- TextView valText = createValueText(context, String.valueOf(cur), accent, density);
- row.addView(label);
- row.addView(valText);
- container.addView(row);
-
- SeekBar seekBar = new SeekBar(context);
- seekBar.setMin(min);
- seekBar.setMax(max);
- seekBar.setProgress(cur);
- applyAccentToSeekBar(seekBar, accent);
-
- seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar sb, int progress, boolean fromUser) {
- if (fromUser) {
- valText.setText(String.valueOf(progress));
- listener.onValueChanged(progress);
- }
- }
- @Override public void onStartTrackingTouch(SeekBar sb) {}
- @Override public void onStopTrackingTouch(SeekBar sb) {}
- });
- addWithMargin(container, seekBar, density);
- }
-
- private static void addToggle(Context context, ViewGroup container, String labelText, boolean isChecked, int accent, float density, ToggleChangeListener listener) {
- LinearLayout row = createRow(context, container, density);
- TextView label = createLabel(context, labelText, density);
- Switch toggle = new Switch(context);
- toggle.setChecked(isChecked);
-
- int[][] states = {{android.R.attr.state_checked}, {}};
- toggle.setThumbTintList(new ColorStateList(states, new int[]{accent, 0xFFA8B0B8}));
- int trackColor = Color.argb(100, Color.red(accent), Color.green(accent), Color.blue(accent));
- toggle.setTrackTintList(new ColorStateList(states, new int[]{trackColor, 0xFF343A40}));
-
- toggle.setOnCheckedChangeListener((btn, checked) -> listener.onToggleChanged(checked));
-
- row.addView(label);
- row.addView(toggle);
- container.addView(row);
- }
-
- private static void addKeybindCapture(Context context, ViewGroup container, String labelText, int currentKey, int accent, float density, ValueChangeListener listener) {
- LinearLayout row = createRow(context, container, density);
- TextView label = createLabel(context, labelText, density);
- Button btn = new Button(context);
- btn.setText(getKeyName(context, currentKey));
- btn.setBackgroundTintList(ColorStateList.valueOf(0xFF24282C));
- btn.setTextColor(accent);
-
- btn.setOnClickListener(v -> {
- AlertDialog.Builder builder = new AlertDialog.Builder(context, R.style.Base_Theme_FullScreen); // using this to match theme
- builder.setTitle(labelText);
- builder.setMessage(R.string.mod_config_press_any_key);
- builder.setCancelable(true);
- builder.setNegativeButton(R.string.cancel, null);
- AlertDialog dialog = builder.create();
- dialog.setOnKeyListener((dialogInterface, keyCode, event) -> {
- if (event.getAction() == KeyEvent.ACTION_DOWN) {
- if (keyCode == KeyEvent.KEYCODE_BACK) {
- dialog.dismiss();
- return true;
- }
- btn.setText(getKeyName(context, keyCode));
- listener.onValueChanged(keyCode);
- dialog.dismiss();
- return true;
- }
- return false;
- });
- dialog.show();
- });
-
- row.addView(label);
- row.addView(btn);
- container.addView(row);
- }
-
- private static LinearLayout createRow(Context context, ViewGroup container, float density) {
- LinearLayout row = new LinearLayout(context);
- row.setOrientation(LinearLayout.HORIZONTAL);
- row.setGravity(android.view.Gravity.CENTER_VERTICAL);
- LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
- params.topMargin = (int)(12 * density);
- row.setLayoutParams(params);
- return row;
- }
-
- private static SeekBar addColorSliderInline(Context context, ViewGroup container, String labelText, int value, int tint, float density, ValueChangeListener listener) {
- LinearLayout row = new LinearLayout(context);
- row.setOrientation(LinearLayout.HORIZONTAL);
- row.setGravity(android.view.Gravity.CENTER_VERTICAL);
-
- TextView tv = new TextView(context);
- tv.setText(labelText);
- tv.setTextColor(0xFFF1F4F6);
- tv.setTextSize(12);
- tv.setTypeface(null, android.graphics.Typeface.BOLD);
- tv.setLayoutParams(new LinearLayout.LayoutParams((int)(20 * density), LinearLayout.LayoutParams.WRAP_CONTENT));
-
- SeekBar sb = new SeekBar(context);
- sb.setMax(255);
- sb.setProgress(value);
- sb.setProgressTintList(ColorStateList.valueOf(tint));
- sb.setThumbTintList(ColorStateList.valueOf(tint));
- sb.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1));
-
- sb.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- if (fromUser) listener.onValueChanged(progress);
- }
- @Override public void onStartTrackingTouch(SeekBar seekBar) {}
- @Override public void onStopTrackingTouch(SeekBar seekBar) {}
- });
-
- row.addView(tv);
- row.addView(sb);
-
- LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
- rowParams.topMargin = (int)(4 * density);
- container.addView(row, rowParams);
- return sb;
- }
-
- private static TextView createLabel(Context context, String text, float density) {
- TextView label = new TextView(context);
- label.setText(text);
- label.setTextColor(0xFFF1F4F6);
- label.setTextSize(14);
- label.setTypeface(null, android.graphics.Typeface.BOLD);
- label.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1));
- return label;
- }
-
- private static TextView createValueText(Context context, String text, int accent, float density) {
- TextView val = new TextView(context);
- val.setText(text);
- val.setTextColor(accent);
- val.setTextSize(13);
- val.setTypeface(null, android.graphics.Typeface.BOLD);
- val.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT));
- return val;
- }
-
- private static void applyAccentToSeekBar(SeekBar seekBar, int accent) {
- seekBar.setProgressTintList(ColorStateList.valueOf(accent));
- seekBar.setThumbTintList(ColorStateList.valueOf(accent));
- }
-
- private static void addWithMargin(ViewGroup container, View view, float density) {
- LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
- params.topMargin = (int)(6 * density);
- container.addView(view, params);
- }
-
- private static String getKeyName(Context context, int keyCode) {
- String keyLabel = KeyEvent.keyCodeToString(keyCode);
- if (keyLabel != null && keyLabel.startsWith("KEYCODE_")) {
- keyLabel = keyLabel.substring(8);
- }
- return keyLabel != null ? keyLabel : context.getString(R.string.mod_config_key_unknown);
- }
-
- private static int parseIntSafe(String s, int fallback) {
- if (s == null || s.isEmpty()) return fallback;
- try { return Integer.parseInt(s); } catch (NumberFormatException e) { return fallback; }
- }
-
- private static float parseFloatSafe(String s, float fallback) {
- if (s == null || s.isEmpty()) return fallback;
- try { return Float.parseFloat(s); } catch (NumberFormatException e) { return fallback; }
- }
-
- interface ValueChangeListener { void onValueChanged(int val); }
- interface ToggleChangeListener { void onToggleChanged(boolean val); }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuAction.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuAction.java
deleted file mode 100644
index ce33ace9..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuAction.java
+++ /dev/null
@@ -1,75 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import java.util.Objects;
-
-/**
- * A one-shot entry in the Mod Menu (M3).
- *
- * The counterpart to a module. A module has an enable state and is drawn with a switch; an action
- * fires once and is drawn with a chevron. Modelling the two as separate types rather than as one
- * entry with a flag is what keeps a call site from accidentally rendering an action as a switch —
- * the mistake that made the menu feel wrong in the first place.
- *
- *
Pure (resource ids and strings only), so the row kind and the ordering rules are unit-testable.
- */
-public final class ModMenuAction {
-
- private final String id;
- private final int titleRes;
- private final int subtitleRes;
- private final String groupId;
- private final int iconRes;
-
- public ModMenuAction(String id, int titleRes, int subtitleRes, String groupId, int iconRes) {
- this.id = Objects.requireNonNull(id, "id");
- this.titleRes = titleRes;
- this.subtitleRes = subtitleRes;
- this.groupId = groupId;
- this.iconRes = iconRes;
- }
-
- public String getId() {
- return id;
- }
-
- public int getTitleRes() {
- return titleRes;
- }
-
- /** The action's one-line explanation, or 0 when it has none. */
- public int getSubtitleRes() {
- return subtitleRes;
- }
-
- /** The section the action belongs to; actions never share a row with a module. */
- public String getGroupId() {
- return groupId;
- }
-
- /** The row's leading icon, so an action row reads as an action at a glance. */
- public int getIconRes() {
- return iconRes;
- }
-
- /** An action is always an action row: it has no enable state (M3). */
- public ModMenuRowKind kind() {
- return ModMenuRowKind.of(false);
- }
-
- /** The stable key used for the list's identity and the saved order. */
- public String getStableKey() {
- return "action:" + id;
- }
-
- @Override
- public boolean equals(Object other) {
- if (this == other) return true;
- if (!(other instanceof ModMenuAction)) return false;
- return id.equals(((ModMenuAction) other).id);
- }
-
- @Override
- public int hashCode() {
- return id.hashCode();
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuActionCatalog.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuActionCatalog.java
deleted file mode 100644
index 96a1a0c8..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuActionCatalog.java
+++ /dev/null
@@ -1,91 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import org.chimeramc.client.R;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * The Mod Menu's one-shot actions (M3).
- *
- *
Kept separate from the module list on purpose: a module is a switch and an action is a
- * chevron, and the two must never be rendered as each other. The catalog is pure, so the action
- * set and its section placement are unit-testable without a device.
- */
-public final class ModMenuActionCatalog {
-
- /** Centre every HUD overlay back to its default position. */
- public static final String ACTION_RESET_HUD = "reset_hud";
- /** Re-scan and re-register every module without restarting the session. */
- public static final String ACTION_RELOAD_MODULES = "reload_modules";
- /** Open the radial wheel's profile manager. */
- public static final String ACTION_MANAGE_WHEEL = "manage_wheel";
- /** Open the overlay layout manager. */
- public static final String ACTION_MANAGE_LAYOUTS = "manage_layouts";
- /** Toggle the screenshot hide-all state, the same action the three-finger tap performs. */
- public static final String ACTION_TOGGLE_HUD = "toggle_hud";
-
- private static final List ACTIONS = Collections.unmodifiableList(build());
-
- private ModMenuActionCatalog() {
- }
-
- private static List build() {
- List actions = new ArrayList<>();
- // The section ids match a module's group id, so the action set follows the same filter the
- // module set does: "Reset HUD Position" sits in the inbuilt section, "Reload Modules" too.
- actions.add(new ModMenuAction(ACTION_RESET_HUD,
- R.string.mod_menu_action_reset_hud,
- R.string.mod_menu_action_reset_hud_desc,
- "inbuilt",
- R.drawable.ic_hud));
- actions.add(new ModMenuAction(ACTION_RELOAD_MODULES,
- R.string.mod_menu_action_reload_mods,
- R.string.mod_menu_action_reload_mods_desc,
- "inbuilt",
- R.drawable.ic_reload));
- actions.add(new ModMenuAction(ACTION_MANAGE_WHEEL,
- R.string.mod_menu_action_manage_wheel,
- R.string.mod_menu_action_manage_wheel_desc,
- "inbuilt",
- R.drawable.ic_wheel));
- actions.add(new ModMenuAction(ACTION_MANAGE_LAYOUTS,
- R.string.mod_menu_action_manage_layouts,
- R.string.mod_menu_action_manage_layouts_desc,
- "inbuilt",
- R.drawable.ic_hud));
- actions.add(new ModMenuAction(ACTION_TOGGLE_HUD,
- R.string.mod_menu_action_toggle_hud,
- R.string.mod_menu_action_toggle_hud_desc,
- "inbuilt",
- R.drawable.ic_screenshot));
- return actions;
- }
-
- /** Every action, in display order. */
- public static List all() {
- return ACTIONS;
- }
-
- /** The actions that belong to one section. */
- public static List forGroup(String groupId) {
- List out = new ArrayList<>();
- for (ModMenuAction action : ACTIONS) {
- if (action.getGroupId() == null ? groupId == null
- : action.getGroupId().equals(groupId)) {
- out.add(action);
- }
- }
- return out;
- }
-
- /** The action with this id, or null. */
- public static ModMenuAction byId(String id) {
- if (id == null) return null;
- for (ModMenuAction action : ACTIONS) {
- if (id.equals(action.getId())) return action;
- }
- return null;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuActionVisibility.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuActionVisibility.java
deleted file mode 100644
index 044c4436..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuActionVisibility.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * The rule that decides whether a one-shot action belongs in the section currently on screen (M3).
- *
- * An action is filed under a module-list section, and the menu's filter is what chooses that
- * section: the PvP filter shows the PvP section, the Inbuilt filter the inbuilt section. Rendering
- * every action on every filter would put "Reset HUD Position" beside the external-mod list, which
- * reads as a bug, so the action set follows the section exactly as the module set does.
- *
- *
The action's group id and a module's group id share one vocabulary ({@code inbuilt}, {@code pvp}),
- * so the rule is a plain comparison. {@code ALL} shows every section at once, so it shows every
- * action. The favourites, enabled and visuals/misc views are a mixed view of several sections
- * rather than a section of their own, so they show none rather than a guessed subset.
- *
- *
Pure and Android-free, so the placement is unit-tested without a device.
- */
-public final class ModMenuActionVisibility {
-
- private ModMenuActionVisibility() {}
-
- /**
- * Whether an action belongs in the section a filter shows.
- *
- * @param filterName the active filter's name ({@code ALL}, {@code INBUILT}, {@code PVP}, …), as
- * the overlay already persists it for the saved order
- * @param actionGroupId the section the action is filed under
- */
- public static boolean isVisibleIn(String filterName, String actionGroupId) {
- if (filterName == null || actionGroupId == null) {
- return false;
- }
- if ("ALL".equals(filterName)) {
- // Every section is on screen at once, so every action is too.
- return true;
- }
- String section = sectionIdFor(filterName);
- return section != null && section.equals(actionGroupId);
- }
-
- /** The module-list section a filter shows, or null when the filter is not a single section. */
- public static String sectionIdFor(String filterName) {
- if (filterName == null) {
- return null;
- }
- switch (filterName) {
- case "INBUILT":
- return "inbuilt";
- case "PVP":
- return "pvp";
- default:
- // ALL is handled by the caller; FAVORITES, ENABLED, EXTERNAL, VISUALS and MISC are
- // mixed views rather than a section.
- return null;
- }
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuAdapter.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuAdapter.java
deleted file mode 100644
index 75bfb308..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuAdapter.java
+++ /dev/null
@@ -1,1176 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.content.res.ColorStateList;
-import android.graphics.Typeface;
-import android.graphics.drawable.GradientDrawable;
-import android.text.TextUtils;
-import android.view.Gravity;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.ImageButton;
-import android.widget.ImageView;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.DiffUtil;
-import androidx.recyclerview.widget.RecyclerView;
-
-import org.chimeramc.client.ui.components.ListDiff;
-import org.chimeramc.client.ui.components.ListDiffDispatch;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.mods.inbuilt.UnifiedMod;
-import org.chimeramc.client.core.mods.inbuilt.model.ModAvailability;
-import org.chimeramc.client.ui.animation.DynamicAnim;
-import org.chimeramc.client.ui.components.UiTokens;
-import org.chimeramc.client.util.TouchTargetHelper;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-
-public class ModMenuAdapter extends RecyclerView.Adapter {
-
- private static final int VIEW_TYPE_GROUP = 0;
- private static final int VIEW_TYPE_MOD = 1;
- private static final int VIEW_TYPE_MOD_COMPACT = 2;
- private static final int VIEW_TYPE_ACTION = 3;
- private static final int VIEW_TYPE_ACTION_COMPACT = 4;
-
- private final List items = new ArrayList<>();
- private final Map toggleStates = new HashMap<>();
- private final Map favoriteStates = new HashMap<>();
- /** The one-shot actions appended after the module sections (M3), in display order. */
- private final List actions = new ArrayList<>();
- private OnModActionListener listener;
- private boolean compactMode;
- private ModMenuTheme theme;
- private ModMenuSettings settings;
- /** The reorderable section id, or null when drag-to-reorder is off for this list (M9). */
- private String reorderSectionId;
- /** Per-module row state, from the registry (M10). Absent means "derive from availability". */
- private final Map rowStates = new HashMap<>();
- /**
- * The row states the visible rows were last bound with.
- *
- * Distinct from {@link #rowStates}, which is what the next bind will use: the diff needs both
- * sides to notice a module the crash guard disabled without the user's toggle changing.
- */
- private final Map boundRowStates = new HashMap<>();
- /** The index a controller has moved the selection to, or NONE. -1 because position 0 is valid. */
- private int controllerFocus = ModMenuNavigation.NONE;
- private RecyclerView recycler;
-
- public ModMenuAdapter(ModMenuTheme theme) {
- this.theme = theme != null ? theme : new ModMenuTheme(null);
- setHasStableIds(true);
- }
-
- public interface OnModActionListener {
- void onToggle(UnifiedMod mod, boolean enabled);
- void onConfig(UnifiedMod mod);
- void onFavoriteChanged(UnifiedMod mod, boolean favorite);
- /** A one-shot action row was tapped (M3). Never a switch. */
- void onAction(String actionId);
- /** A row's reason badge was tapped, so the overlay can explain the failure (M10). */
- void onRowReason(String stableKey);
- /** The user finished a drag-to-reorder move within a section (M9). */
- void onReorder(String sectionId, List orderedKeys);
- }
-
- public void setOnModActionListener(OnModActionListener listener) {
- this.listener = listener;
- }
-
- /** Applies the menu's appearance settings (M1, M6): accent, density and motion scheme. */
- public void setSettings(ModMenuSettings settings) {
- this.settings = settings;
- if (settings != null) {
- this.theme = new ModMenuTheme(recycler != null ? recycler.getContext() : null, settings);
- }
- }
-
- public ModMenuSettings getSettings() {
- return settings;
- }
-
- /**
- * Replaces the per-module row states (M10).
- *
- * The overlay owns what it knows about the registry (did the module register? has it crashed
- * twice? is there a hook for this build?) and hands the answer in; the adapter only renders it.
- */
- public void setRowStates(Map states) {
- rowStates.clear();
- if (states != null) rowStates.putAll(states);
- }
-
- /**
- * Commits the list's current order for a section (M9).
- *
- * Called when a drag finishes, so the manager is written once per gesture rather than once
- * per intermediate move.
- */
- public void onReorderFinished(String sectionId) {
- if (listener != null && sectionId != null) {
- listener.onReorder(sectionId, currentSectionOrder());
- }
- }
-
- /** Enables drag-to-reorder for one section (M9). */
- public void setReorderSection(String sectionId) {
- this.reorderSectionId = sectionId;
- }
-
- public String getReorderSection() {
- return reorderSectionId;
- }
-
- /** True when the list is currently reorderable. */
- public boolean isReorderEnabled() {
- return reorderSectionId != null;
- }
-
- /**
- * Moves a module one slot within its section, returning the new order (M9).
- *
- *
Group headers and one-shot action rows are not movable and a module may not cross into
- * another section: the header rule (one header per contiguous run) would break, and the saved
- * order is per section. The move returns the resulting key order so the caller can persist it,
- * and returns null when it refuses — the refusal has to be visible, because
- * {@code ItemTouchHelper.onMove} treats a non-null return as "the drag was accepted" and lets a
- * row that was never actually moved keep floating under the finger.
- */
- public List moveModule(int fromPosition, int toPosition) {
- if (fromPosition < 0 || toPosition < 0
- || fromPosition >= items.size() || toPosition >= items.size()) {
- return null;
- }
- MenuItem from = items.get(fromPosition);
- MenuItem to = items.get(toPosition);
- // A group header and a one-shot action row both have no position of their own, and neither
- // carries a module, so neither can be the source or the target of a move.
- if (from.mod == null || to.mod == null) return null;
- if (!Objects.equals(from.mod.getGroupId(), to.mod.getGroupId())) {
- return null;
- }
- items.remove(fromPosition);
- items.add(toPosition, from);
- notifyItemMoved(fromPosition, toPosition);
- return currentSectionOrder();
- }
-
- /**
- * Whether a drag may move one row onto another (M9).
- *
- * Only two modules of the same section qualify: a header has no position of its own, and
- * crossing sections would break the one-header-per-contiguous-run rule the list renders by.
- */
- public boolean canReorderBetween(RecyclerView.ViewHolder from, RecyclerView.ViewHolder to) {
- if (from == null || to == null) return false;
- int a = from.getBindingAdapterPosition();
- int b = to.getBindingAdapterPosition();
- if (a == RecyclerView.NO_POSITION || b == RecyclerView.NO_POSITION) return false;
- if (a >= items.size() || b >= items.size()) return false;
- MenuItem first = items.get(a);
- MenuItem second = items.get(b);
- // A header or an action row has no module to compare, so it is not a valid drop target.
- if (first.mod == null || second.mod == null) return false;
- return Objects.equals(first.mod.getGroupId(), second.mod.getGroupId());
- }
-
- /** The module keys in their current list order, for persistence (M9). */
- public List currentSectionOrder() {
- List out = new ArrayList<>();
- for (MenuItem item : items) {
- if (!item.isGroup() && item.mod != null) out.add(item.mod.getStableKey());
- }
- return out;
- }
-
- /**
- * Reorders the module list: alphabetical by default, then the saved order on top (M9, Phase 10).
- *
- * The shipped order is now alphabetical by the module's display name, so a feature is found
- * by its name rather than by where the catalogue happened to register it. A saved order is then
- * applied on top, so a deliberate arrangement survives: a key that is no longer present is
- * dropped, and a module the saved order does not mention keeps its alphabetical place. That is
- * what makes a saved order survive a module being added or removed by an update.
- */
- public void applySavedOrder(List savedOrder) {
- if (items.isEmpty()) return;
- // Group the modules by their group id, preserving the shipped group order, and sort each
- // group by the saved order through ModMenuOrdering. A module the saved order does not
- // mention keeps its shipped place at the end of its own group, so an update that adds a
- // module does not scramble the arrangement.
- List groupOrder = new ArrayList<>();
- Map> byGroup = new LinkedHashMap<>();
- Map groupNames = new HashMap<>();
- Map groupLabelFlags = new HashMap<>();
- // The trailing actions block is not reorderable and is not part of any module group; it is
- // carried through untouched and re-appended, so a saved order cannot drop it.
- List actionBlock = new ArrayList<>();
- boolean inActionBlock = false;
- for (MenuItem item : items) {
- if (item.isAction() || item.isActionsHeader()) {
- inActionBlock = true;
- actionBlock.add(item);
- continue;
- }
- if (inActionBlock) {
- // The action block is always last; anything after it is unexpected, so keep it.
- actionBlock.add(item);
- continue;
- }
- if (item.isGroup()) {
- groupNames.put(item.groupId, item.groupName);
- continue;
- }
- String gid = item.mod.getGroupId();
- if (!byGroup.containsKey(gid)) {
- byGroup.put(gid, new ArrayList<>());
- groupOrder.add(gid);
- }
- groupLabelFlags.putIfAbsent(gid, item.showGroupLabel);
- byGroup.get(gid).add(item);
- }
- if (byGroup.isEmpty()) {
- // Nothing to reorder; the action block (if any) is already in place.
- return;
- }
-
- List reordered = new ArrayList<>();
- for (String gid : groupOrder) {
- List group = byGroup.get(gid);
- // The ordering rule lives in ModMenuOrdering so it is unit-tested; this only applies
- // the resulting key order to the row objects.
- List shippedKeys = new ArrayList<>();
- Map byKey = new HashMap<>();
- Map nameByKey = new HashMap<>();
- for (MenuItem item : group) {
- String key = item.mod.getStableKey();
- shippedKeys.add(key);
- byKey.put(key, item);
- nameByKey.put(key, item.mod.getName());
- }
- // Phase 10: the shipped base order is alphabetical by display name; a saved order is
- // applied on top, so a deliberate arrangement still wins.
- List orderedGroup = new ArrayList<>();
- for (String key : ModMenuOrdering.apply(
- ModMenuOrdering.alphabetically(shippedKeys, nameByKey::get), savedOrder)) {
- MenuItem item = byKey.get(key);
- if (item != null) orderedGroup.add(item);
- }
- group = orderedGroup;
- // Rebuild the section header. Dropping it here would silently flatten every group in
- // the non-compact layout, which is the whole navigation structure.
- if (!compactMode) {
- reordered.add(MenuItem.group(gid, groupNames.get(gid)));
- }
- for (MenuItem item : group) {
- boolean showLabel = compactMode
- && Boolean.TRUE.equals(groupLabelFlags.get(gid));
- reordered.add(item.showGroupLabel == showLabel
- ? item : MenuItem.mod(item.mod, showLabel));
- }
- }
- if (reordered.isEmpty()) return;
- reordered.addAll(actionBlock);
- // A saved order usually moves one module, and the rows around it are the same rows. Diffing
- // lets the list animate that one move instead of rebuilding every card while the user is
- // looking at the arrangement they just chose.
- ListDiff.Result diff = ListDiff.compute(items, reordered,
- MenuItem::stableKey,
- (oldItem, freshItem) -> oldItem.showGroupLabel == freshItem.showGroupLabel
- && Objects.equals(oldItem.mod == null ? null : oldItem.mod.getName(),
- freshItem.mod == null ? null : freshItem.mod.getName()));
- items.clear();
- items.addAll(reordered);
- ListDiffDispatch.apply(this, diff);
- }
-
- @Override
- public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
- super.onAttachedToRecyclerView(recyclerView);
- this.recycler = recyclerView;
- }
-
- @Override
- public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) {
- super.onDetachedFromRecyclerView(recyclerView);
- this.recycler = null;
- }
-
- /** Columns per row, so vertical moves step by row exactly as the grid lays out. */
- private int columnCount() {
- if (recycler != null && recycler.getLayoutManager() instanceof androidx.recyclerview.widget.GridLayoutManager) {
- return ((androidx.recyclerview.widget.GridLayoutManager) recycler.getLayoutManager()).getSpanCount();
- }
- return compactMode ? 1 : 4;
- }
-
- /** One flag per item: group headers cannot be selected, module cards can. */
- private boolean[] selectableFlags() {
- boolean[] flags = new boolean[items.size()];
- for (int i = 0; i < items.size(); i++) {
- MenuItem item = items.get(i);
- if (item.isActionsHeader()) {
- flags[i] = false;
- } else if (item.isAction()) {
- // An action row is always interactive: it has no enable state to be unavailable.
- flags[i] = true;
- } else {
- flags[i] = !item.isGroup() && item.mod != null
- && ModAvailability.isInteractive(item.mod.getId());
- }
- }
- return flags;
- }
-
- /** One flag per item: a group header spans its own row, so the layout has to say so. */
- private boolean[] fullRowFlags() {
- boolean[] flags = new boolean[items.size()];
- for (int i = 0; i < items.size(); i++) {
- MenuItem item = items.get(i);
- flags[i] = item.isGroup() || item.isActionsHeader();
- }
- return flags;
- }
-
- /**
- * Moves the controller selection by one directional press.
- *
- * Returns true when the press was consumed, so the caller can swallow it and the stick does
- * not also reach the game. The move is computed by {@link ModMenuNavigation}, which is pure and
- * tested; this method only applies the result to the list.
- */
- public boolean handleControllerNavigation(ModMenuNavigation.Direction direction) {
- if (items.isEmpty()) return false;
- int target = ModMenuNavigation.move(controllerFocus, columnCount(), fullRowFlags(),
- selectableFlags(), direction);
- if (target == ModMenuNavigation.NONE || target == controllerFocus) {
- // Nothing to move to: still consumed, so a press at the edge does not make the stick
- // act on the game behind the menu.
- return controllerFocus != ModMenuNavigation.NONE;
- }
- int previous = controllerFocus;
- controllerFocus = target;
- if (previous >= 0) notifyItemChanged(previous);
- notifyItemChanged(target);
- if (recycler != null) recycler.smoothScrollToPosition(target);
- return true;
- }
-
- /**
- * Activates the focused card: a module toggles (or, when unavailable, says why), a card with a
- * gear opens its settings.
- *
- *
The same rules the touch path uses, so a controller cannot switch on a module the touch
- * path refuses.
- */
- public boolean handleControllerSelect() {
- if (controllerFocus < 0 || controllerFocus >= items.size()) return false;
- MenuItem item = items.get(controllerFocus);
- if (item.isGroup() || item.isActionsHeader()) return false;
- if (item.isAction()) {
- // An action row fires once, exactly as its touch path does. Never a switch.
- if (listener != null) listener.onAction(item.action.getId());
- return true;
- }
- if (item.mod == null) return false;
- if (!ModAvailability.isInteractive(item.mod.getId())) return true;
- if (listener != null) listener.onToggle(item.mod, !toggleStates.getOrDefault(item.mod.getStableKey(), false));
- return true;
- }
-
- /** Clears the controller selection, e.g. when the menu closes. */
- public void clearControllerFocus() {
- if (controllerFocus == ModMenuNavigation.NONE) return;
- int previous = controllerFocus;
- controllerFocus = ModMenuNavigation.NONE;
- if (previous >= 0 && previous < items.size()) notifyItemChanged(previous);
- }
-
- /**
- * Switches between the grid and the single-column list.
- *
- *
Every card changes its view type, so the rows are all re-created: this is a range change
- * over the whole list rather than a dataset change, which keeps the RecyclerView's item
- * animations and its stable ids working across the switch.
- */
- public void setCompactMode(boolean compactMode) {
- if (this.compactMode == compactMode) return;
- this.compactMode = compactMode;
- items.clear();
- toggleStates.clear();
- favoriteStates.clear();
- // Every row is re-created on the next bind, so the bound snapshot is meaningless; clear it
- // or the next diff would compare against a list that no longer matches what is on screen.
- boundRowStates.clear();
- if (getItemCount() > 0) {
- notifyItemRangeChanged(0, getItemCount());
- }
- }
-
- /**
- * Replaces the list with the modules and the one-shot actions for the current section (M3).
- *
- *
Actions are appended after the modules under their own {@code ACTIONS} header, never mixed
- * into a module's section: a switch and a chevron must not sit in the same run, because that is
- * exactly the "is this a toggle or a button?" ambiguity the split removes.
- *
- *
There is deliberately no module-list-only overload. One existed and the density toggle
- * called it, which silently dropped the ACTIONS block until the next filter change; making the
- * action list a required argument means a caller cannot rebuild the list without deciding what
- * happens to the actions.
- */
- public void updateMods(List mods, Set favoriteKeys,
- List sectionActions) {
- List nextItems = new ArrayList<>();
- Map nextToggleStates = new HashMap<>();
- Map nextFavoriteStates = new HashMap<>();
- String lastGroupId = null;
- for (UnifiedMod mod : mods) {
- boolean firstInGroup = !Objects.equals(mod.getGroupId(), lastGroupId);
- if (!compactMode && firstInGroup) {
- nextItems.add(MenuItem.group(mod.getGroupId(), mod.getGroupName()));
- }
- boolean showGroupLabel = compactMode
- && firstInGroup
- && mod.getSource() == UnifiedMod.Source.EXTERNAL;
- nextItems.add(MenuItem.mod(mod, showGroupLabel));
- lastGroupId = mod.getGroupId();
- nextToggleStates.put(mod.getStableKey(), mod.isEnabled());
- nextFavoriteStates.put(mod.getStableKey(),
- favoriteKeys != null && favoriteKeys.contains(mod.getStableKey()));
- }
-
- List nextActions = new ArrayList<>();
- if (sectionActions != null) {
- nextActions.addAll(sectionActions);
- }
- if (!nextActions.isEmpty()) {
- nextItems.add(MenuItem.actionsHeader());
- for (ModMenuAction action : nextActions) {
- nextItems.add(MenuItem.action(action));
- }
- }
-
- List oldItems = new ArrayList<>(items);
- Map oldToggleStates = new HashMap<>(toggleStates);
- Map oldFavoriteStates = new HashMap<>(favoriteStates);
- DiffUtil.DiffResult diff = DiffUtil.calculateDiff(new DiffUtil.Callback() {
- @Override
- public int getOldListSize() {
- return oldItems.size();
- }
-
- @Override
- public int getNewListSize() {
- return nextItems.size();
- }
-
- @Override
- public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
- return oldItems.get(oldItemPosition).stableKey()
- .equals(nextItems.get(newItemPosition).stableKey());
- }
-
- @Override
- public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
- MenuItem oldItem = oldItems.get(oldItemPosition);
- MenuItem newItem = nextItems.get(newItemPosition);
- if (oldItem.isGroup() || newItem.isGroup()) {
- return oldItem.isGroup() == newItem.isGroup()
- && Objects.equals(oldItem.groupName, newItem.groupName);
- }
- if (oldItem.isAction() || newItem.isAction()) {
- // An action row is static; its identity (the stable key) is the whole content.
- return oldItem.isAction() == newItem.isAction()
- && Objects.equals(oldItem.stableKey(), newItem.stableKey());
- }
- String oldKey = oldItem.mod.getStableKey();
- String newKey = newItem.mod.getStableKey();
- boolean oldEnabled = oldToggleStates.getOrDefault(oldKey, false);
- boolean newEnabled = nextToggleStates.getOrDefault(newKey, false);
- // The content rule lives in ModMenuRowContent so it is unit-tested; the row state is
- // compared as "what the old row was bound with" against "what the new row will bind
- // with", which is what catches a module the crash guard disabled without the user's
- // toggle changing.
- return ModMenuRowContent.sameContent(
- oldItem.mod.getName(), newItem.mod.getName(),
- oldItem.mod.getDescription(), newItem.mod.getDescription(),
- oldItem.mod.hasConfig(), newItem.mod.hasConfig(),
- oldItem.showGroupLabel, newItem.showGroupLabel,
- oldEnabled, newEnabled,
- ModMenuRowContent.resolve(boundRowStates, oldKey,
- ModAvailability.isInteractive(oldItem.mod.getId()), oldEnabled),
- ModMenuRowContent.resolve(rowStates, newKey,
- ModAvailability.isInteractive(newItem.mod.getId()), newEnabled),
- oldFavoriteStates.getOrDefault(oldKey, false),
- nextFavoriteStates.getOrDefault(newKey, false));
- }
- }, false);
-
- items.clear();
- items.addAll(nextItems);
- actions.clear();
- actions.addAll(nextActions);
- toggleStates.clear();
- toggleStates.putAll(nextToggleStates);
- favoriteStates.clear();
- favoriteStates.putAll(nextFavoriteStates);
- // The rows about to be bound use the current states; record them so the next diff compares
- // against what is actually on screen rather than against itself.
- boundRowStates.clear();
- boundRowStates.putAll(rowStates);
- diff.dispatchUpdatesTo(this);
- // A filter change or a refresh renumbers every position, so the controller selection would
- // point at a different card than the one that was highlighted. Drop it rather than let the
- // next A press toggle whatever moved under the index.
- controllerFocus = ModMenuNavigation.NONE;
- }
-
- public boolean isGroupHeader(int position) {
- return position >= 0 && position < items.size() && items.get(position).isGroup();
- }
-
- /** The theme is baked into every card, so every row's content changed. */
- public void setTheme(ModMenuTheme theme) {
- this.theme = theme != null ? theme : new ModMenuTheme(null);
- if (getItemCount() > 0) {
- notifyItemRangeChanged(0, getItemCount());
- }
- }
-
- @NonNull
- @Override
- public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
- if (viewType == VIEW_TYPE_GROUP) {
- float density = parent.getResources().getDisplayMetrics().density;
- android.widget.LinearLayout row = new android.widget.LinearLayout(parent.getContext());
- row.setOrientation(android.widget.LinearLayout.HORIZONTAL);
- row.setGravity(Gravity.CENTER_VERTICAL);
- RecyclerView.LayoutParams params = new RecyclerView.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, (int) (38 * density));
- params.setMargins((int) (8 * density), (int) (8 * density),
- (int) (8 * density), 0);
- row.setLayoutParams(params);
-
- View bar = new View(parent.getContext());
- android.widget.LinearLayout.LayoutParams barParams =
- new android.widget.LinearLayout.LayoutParams((int) (3 * density), (int) (16 * density));
- barParams.setMarginEnd((int) (8 * density));
- bar.setLayoutParams(barParams);
- row.addView(bar);
-
- TextView title = new TextView(parent.getContext());
- title.setLayoutParams(new android.widget.LinearLayout.LayoutParams(
- 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
- title.setGravity(Gravity.CENTER_VERTICAL);
- title.setTextSize(11);
- title.setTypeface(null, Typeface.BOLD);
- title.setSingleLine(true);
- title.setEllipsize(TextUtils.TruncateAt.END);
- row.addView(title);
-
- return new GroupViewHolder(row, bar, title);
- }
-
- int layout = viewType == VIEW_TYPE_MOD_COMPACT || viewType == VIEW_TYPE_ACTION_COMPACT
- ? R.layout.item_mod_menu_compact
- : R.layout.item_mod_menu_card;
- View v = LayoutInflater.from(parent.getContext()).inflate(layout, parent, false);
- return new ModViewHolder(v);
- }
-
- @Override
- public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
- MenuItem item = items.get(position);
- if (item.isGroup()) {
- GroupViewHolder group = (GroupViewHolder) holder;
- group.title.setText(item.groupName);
- group.title.setTextColor(theme.groupLabelColor());
- GradientDrawable barBg = new GradientDrawable();
- barBg.setShape(GradientDrawable.RECTANGLE);
- barBg.setCornerRadius(2f * group.bar.getResources().getDisplayMetrics().density);
- barBg.setColor(theme.groupAccent());
- group.bar.setBackground(barBg);
- return;
- }
-
- if (item.isActionsHeader()) {
- GroupViewHolder group = (GroupViewHolder) holder;
- group.title.setText(R.string.mod_menu_actions_header);
- group.title.setTextColor(theme.groupLabelColor());
- GradientDrawable barBg = new GradientDrawable();
- barBg.setShape(GradientDrawable.RECTANGLE);
- barBg.setCornerRadius(2f * group.bar.getResources().getDisplayMetrics().density);
- barBg.setColor(theme.groupAccent());
- group.bar.setBackground(barBg);
- return;
- }
-
- ModViewHolder modHolder = (ModViewHolder) holder;
- if (item.isAction()) {
- bindActionRow(modHolder, item, position);
- return;
- }
- UnifiedMod mod = item.mod;
-
- // A recycled holder may have been an action row, which hides the module controls. Restore
- // them before binding a module, or the switch stays gone and the module looks untoggleable.
- restoreModuleRowControls(modHolder);
-
- modHolder.name.setText(mod.getName());
- if (modHolder.groupText != null) {
- // The caption: the module's short description, or the group name when it has none.
- String caption = mod.getDescription();
- if (caption == null || caption.trim().isEmpty()) {
- caption = item.showGroupLabel ? mod.getGroupName() : null;
- }
- if (caption != null && !caption.trim().isEmpty()) {
- modHolder.groupText.setVisibility(View.VISIBLE);
- modHolder.groupText.setText(caption);
- } else {
- modHolder.groupText.setVisibility(View.GONE);
- }
- }
-
- if (mod.getSource() == UnifiedMod.Source.INBUILT) {
- modHolder.icon.setImageResource(ModIconHelper.getModIcon(mod.getId()));
- modHolder.icon.setImageTintList(null);
- modHolder.icon.setColorFilter(null);
- } else {
- modHolder.icon.setImageResource(R.drawable.ic_caramel_mod);
- modHolder.icon.setImageTintList(null);
- modHolder.icon.setColorFilter(null);
- }
-
- boolean isEnabled = toggleStates.getOrDefault(mod.getStableKey(), false);
- boolean available = ModAvailability.isInteractive(mod.getId());
- ModMenuRowState rowState = rowStateFor(mod, available, isEnabled);
- updateStatusView(modHolder, isEnabled, available);
- updateAccentBar(modHolder, mod.getGroupId(), isEnabled);
- updateFavoriteView(modHolder, favoriteStates.getOrDefault(mod.getStableKey(), false));
- updateUnavailableView(modHolder, mod);
- updateRowStateView(modHolder, mod, rowState);
-
- // The switch is the state control: a smooth slide rather than a tapped status pill. It is
- // set without animating on bind (a recycled view must not slide from the previous item's
- // state), and it is the one control that both shows and changes the state.
- if (modHolder.switchView != null) {
- // The switch follows the row state (M10): a broken module is not merely off, its
- // control is disabled and shows the error track.
- modHolder.switchView.setInteractive(rowState.isInteractive());
- modHolder.switchView.setFailed(rowState.isBroken());
- // The switch's label is the module name, so TalkBack reads "FPS Counter, On, Switch"
- // rather than an unnamed switch beside a separate text view.
- modHolder.switchView.setLabel(mod.getName());
- modHolder.switchView.setChecked(isEnabled && !rowState.isBroken(), false);
- modHolder.switchView.setReducedMotion(settings != null
- && settings.effectiveMotion(false, false) == ModMenuSettings.Motion.REDUCED);
- // M6: the toggle-style setting reaches the control, so choosing the checkbox variant
- // actually draws a checkbox rather than leaving the stored preference inert.
- if (settings != null) {
- modHolder.switchView.setToggleStyle(settings.toggleStyle());
- }
- modHolder.switchView.setOnCheckedChangeListener(null);
- modHolder.switchView.setOnCheckedChangeListener(checked -> {
- if (!rowState.isInteractive()) {
- if (listener != null) listener.onRowReason(mod.getStableKey());
- return;
- }
- toggleStates.put(mod.getStableKey(), checked);
- updateStatusView(modHolder, checked, true);
- updateAccentBar(modHolder, mod.getGroupId(), checked);
- org.chimeramc.client.ui.animation.ModToggleAnim.play(
- modHolder.icon, mod.getId(), checked);
- if (listener != null) listener.onToggle(mod, checked);
- });
- }
-
- View.OnClickListener toggleClick = v -> {
- // A module that cannot do its job must not look toggleable. A broken row opens its
- // reason sheet (M10) rather than flipping a switch that changes nothing on screen.
- if (!rowState.isInteractive()) {
- if (listener != null) {
- listener.onRowReason(mod.getStableKey());
- } else if (modHolder.unavailableText != null) {
- // The reason-specific wording: a server-decided module is not waiting for a
- // feed, and a module waiting on a backend capability is not either, so a single
- // "no game data" line would be wrong for both.
- int reason = unavailableReasonString(mod.getId());
- android.widget.Toast.makeText(modHolder.itemView.getContext(),
- reason, android.widget.Toast.LENGTH_SHORT).show();
- }
- return;
- }
- boolean newState = !toggleStates.getOrDefault(mod.getStableKey(), false);
- toggleStates.put(mod.getStableKey(), newState);
- updateStatusView(modHolder, newState, true);
- updateAccentBar(modHolder, mod.getGroupId(), newState);
- if (modHolder.switchView != null) {
- modHolder.switchView.setChecked(newState, true);
- }
- // Per-icon behaviour on flip, not a generic bounce: Armor HUD "equips", the Voice mic
- // pulses. Only modules with distinct iconography animate; the rest fall through.
- org.chimeramc.client.ui.animation.ModToggleAnim.play(
- modHolder.icon, mod.getId(), newState);
- if (listener != null) {
- listener.onToggle(mod, newState);
- }
- };
-
- modHolder.itemView.setOnClickListener(toggleClick);
- if (modHolder.statusText != null) modHolder.statusText.setOnClickListener(toggleClick);
- modHolder.icon.setOnClickListener(toggleClick);
- DynamicAnim.applyPressScale(modHolder.itemView);
-
- modHolder.favoriteBtn.setOnClickListener(v -> {
- boolean favorite = !favoriteStates.getOrDefault(mod.getStableKey(), false);
- favoriteStates.put(mod.getStableKey(), favorite);
- updateFavoriteView(modHolder, favorite);
- if (listener != null) {
- listener.onFavoriteChanged(mod, favorite);
- }
- });
-
- if (mod.hasConfig() && available) {
- modHolder.configBtn.setVisibility(View.VISIBLE);
- modHolder.configBtn.setOnClickListener(v -> {
- if (listener != null) {
- listener.onConfig(mod);
- }
- });
- } else {
- modHolder.configBtn.setVisibility(View.GONE);
- }
-
- updateCardState(modHolder, isEnabled, available);
- applyControllerFocus(modHolder, position);
- }
-
- /**
- * Dresses the card a controller has selected with an accent ring.
- *
- * This is why the overlay root is unfocusable: the platform's own focus highlight is the
- * white wash that made the menu look broken, so the selection is drawn here instead, in the
- * theme accent, on exactly one card.
- */
- private void applyControllerFocus(ModViewHolder holder, int position) {
- boolean focused = position == controllerFocus;
- float density = holder.itemView.getResources().getDisplayMetrics().density;
- if (focused) {
- android.graphics.drawable.GradientDrawable ring = new android.graphics.drawable.GradientDrawable();
- ring.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
- ring.setCornerRadius(14f * density);
- ring.setColor(0x00000000);
- ring.setStroke(Math.max(2, (int) (2f * density)), theme.accent());
- holder.itemView.setForeground(ring);
- holder.itemView.setAlpha(1f);
- holder.itemView.setTranslationZ(6f * density);
- } else {
- holder.itemView.setForeground(null);
- holder.itemView.setTranslationZ(0f);
- }
- }
-
- /**
- * Shows or hides the "doesn't work or is bugged" outline.
- *
- *
A module whose game feed has no provider is not merely off — it cannot be turned on. The
- * label is the whole point: without it the card looks like every other disabled module and the
- * player assumes their toggle is broken.
- */
- private void updateUnavailableView(ModViewHolder holder, UnifiedMod mod) {
- if (holder.unavailableText == null) return;
- boolean unavailable = ModAvailability.isUnavailable(mod.getId());
- holder.unavailableText.setVisibility(unavailable ? View.VISIBLE : View.GONE);
- }
-
- @Override
- public int getItemViewType(int position) {
- MenuItem item = items.get(position);
- if (item.isGroup() || item.isActionsHeader()) return VIEW_TYPE_GROUP;
- if (item.isAction()) return compactMode ? VIEW_TYPE_ACTION_COMPACT : VIEW_TYPE_ACTION;
- return compactMode ? VIEW_TYPE_MOD_COMPACT : VIEW_TYPE_MOD;
- }
-
- /**
- * Restores the controls an action row hides, for a holder recycled from an action to a module.
- *
- *
The favourite star's visibility is the module path's to set (a module without a favourite
- * action still shows the star), so it is always re-shown here; the config gear and the switch
- * are shown because a module always has a state to display. A module with no config hides the
- * gear later in the bind.
- */
- private void restoreModuleRowControls(ModViewHolder holder) {
- if (holder.switchView != null) holder.switchView.setVisibility(View.VISIBLE);
- if (holder.favoriteBtn != null) holder.favoriteBtn.setVisibility(View.VISIBLE);
- if (holder.configBtn != null) holder.configBtn.setVisibility(View.VISIBLE);
- if (holder.chevron != null) holder.chevron.setVisibility(View.GONE);
- }
-
- /**
- * Binds a one-shot action row (M3): icon, title, one-line description and a chevron.
- *
- *
An action is not a module, so the row hides every module control — the switch, the
- * favourite star and the settings gear — and shows the chevron instead. Tapping the row fires
- * the action exactly once; there is no state for the row to show, which is the point.
- */
- private void bindActionRow(ModViewHolder holder, MenuItem item, int position) {
- ModMenuAction action = item.action;
- holder.name.setText(action.getTitleRes());
-
- if (holder.groupText != null) {
- int subtitleRes = action.getSubtitleRes();
- if (subtitleRes != 0) {
- holder.groupText.setVisibility(View.VISIBLE);
- holder.groupText.setText(subtitleRes);
- } else {
- holder.groupText.setVisibility(View.GONE);
- }
- }
-
- holder.icon.setImageResource(action.getIconRes());
- holder.icon.setImageTintList(null);
- holder.icon.setColorFilter(theme.accent());
-
- // The action's own controls: a chevron, and none of the module controls.
- if (holder.chevron != null) holder.chevron.setVisibility(View.VISIBLE);
- if (holder.switchView != null) holder.switchView.setVisibility(View.GONE);
- if (holder.favoriteBtn != null) holder.favoriteBtn.setVisibility(View.GONE);
- if (holder.configBtn != null) holder.configBtn.setVisibility(View.GONE);
- if (holder.statusDot != null) holder.statusDot.setVisibility(View.GONE);
- if (holder.reasonText != null) holder.reasonText.setVisibility(View.GONE);
- if (holder.unavailableText != null) holder.unavailableText.setVisibility(View.GONE);
-
- // An action has no state, so its accent bar is always the section's accent rather than an
- // "enabled" wash.
- if (holder.accentBar != null) {
- GradientDrawable bar = new GradientDrawable();
- bar.setShape(GradientDrawable.RECTANGLE);
- bar.setColor(theme.accent());
- holder.accentBar.setBackground(bar);
- holder.accentBar.setVisibility(View.VISIBLE);
- }
-
- View.OnClickListener click = v -> {
- if (listener != null) listener.onAction(action.getId());
- };
- holder.itemView.setOnClickListener(click);
- if (holder.icon != null) holder.icon.setOnClickListener(click);
- DynamicAnim.applyPressScale(holder.itemView);
- applyControllerFocus(holder, position);
- }
-
- private void updateFavoriteView(ModViewHolder holder, boolean favorite) {
- int color = favorite ? UiTokens.WARNING : UiTokens.TEXT_SECONDARY;
- holder.favoriteBtn.setImageTintList(ColorStateList.valueOf(color));
- holder.favoriteBtn.setAlpha(favorite ? 1f : 0.9f);
- holder.favoriteBtn.setContentDescription(holder.favoriteBtn.getContext().getString(
- favorite ? R.string.mod_menu_unfavorite : R.string.mod_menu_favorite));
- }
-
- private void updateStatusView(ModViewHolder holder, boolean enabled,
- boolean available) {
- // The layout is now a switch, not a pill: there is no status TextView. The switch's own
- // state is the indicator, so this only keeps the card's surface in step.
- updateCardState(holder, enabled, available);
- }
-
- /** Paints the top edge strip so each card carries the accent. */
- private void updateAccentBar(ModViewHolder holder, String groupId, boolean enabled) {
- if (holder.accentBar == null) return;
- int color = theme.accent();
- GradientDrawable bar = new GradientDrawable();
- bar.setShape(GradientDrawable.RECTANGLE);
- bar.setColor(enabled ? color : UiTokens.withAlpha(color, 0x40));
- holder.accentBar.setBackground(bar);
- }
-
- /**
- * The row state for a module (M10).
- *
- *
The overlay can supply an explicit state — it knows about crashes and about builds with no
- * hook — and with none the state is derived from what the list itself knows. Deriving rather
- * than defaulting means a row is never silently presented as healthy.
- */
- private ModMenuRowState rowStateFor(UnifiedMod mod, boolean available, boolean enabled) {
- ModMenuRowState explicit = rowStates.get(mod.getStableKey());
- if (explicit != null) return explicit;
- return ModMenuRowState.of(true, available, false, enabled);
- }
-
-
-
- /**
- * Renders the failure surfacing for a row (M10).
- *
- *
A broken row is dimmed, its dot is coloured, its reason is stated in one line and its
- * switch is forced off. This is the part most mod menus skip, and it is what turns the worst
- * support burden into a self-explaining screen.
- */
- private void updateRowStateView(ModViewHolder holder, UnifiedMod mod, ModMenuRowState state) {
- if (holder.statusDot != null) {
- int color = ModMenuPalette.dotFor(state);
- if (color == 0) {
- holder.statusDot.setVisibility(View.GONE);
- } else {
- android.graphics.drawable.GradientDrawable dot =
- new android.graphics.drawable.GradientDrawable();
- dot.setShape(android.graphics.drawable.GradientDrawable.OVAL);
- dot.setColor(color);
- holder.statusDot.setBackground(dot);
- holder.statusDot.setVisibility(View.VISIBLE);
- }
- }
- if (holder.reasonText != null) {
- String reason = reasonFor(mod, state);
- if (reason == null || reason.trim().isEmpty()) {
- holder.reasonText.setVisibility(View.GONE);
- } else {
- holder.reasonText.setText(reason);
- holder.reasonText.setVisibility(View.VISIBLE);
- }
- }
- if (state.isBroken()) {
- holder.itemView.setAlpha(theme.rowAlpha(state));
- if (holder.switchView != null) holder.switchView.setChecked(false, false);
- }
- }
-
- /** The message for a module's badge, chosen from the distinct unavailable reasons. */
- private static int unavailableReasonString(String modId) {
- String reason = ModAvailability.unavailableReason(modId);
- if (ModAvailability.REASON_SERVER_DECIDED.equals(reason)) {
- return R.string.mod_unavailable_reason_server;
- }
- if (ModAvailability.REASON_NO_BACKEND.equals(reason)) {
- return R.string.mod_unavailable_reason_backend;
- }
- return R.string.mod_unavailable_reason;
- }
-
- /**
- * The one-line reason a row cannot run, or null when it can.
- *
- *
The wording is specific on purpose: a module with no hook on this build, a module whose
- * game feed has no provider and a module waiting on a backend capability are different problems
- * with different fixes, and a generic "unavailable" tells the player none of them.
- */
- private String reasonFor(UnifiedMod mod, ModMenuRowState state) {
- android.content.Context context = holderContext();
- if (context == null) return null;
- switch (state) {
- case FAILED:
- return context.getString(R.string.mod_menu_row_failed);
- case CRASHED:
- return context.getString(R.string.mod_menu_row_crashed);
- case UNSUPPORTED:
- if (ModAvailability.isUnavailable(mod.getId())) {
- return context.getString(unavailableReasonString(mod.getId()));
- }
- return context.getString(R.string.mod_menu_row_unsupported);
- default:
- return null;
- }
- }
-
- /**
- * Applies the density's row height to a card's row (M6).
- *
- *
The row is found from the switch rather than by child index: the card's first child is the
- * accent strip, so an index walk reached a 2dp view and did nothing. The row's own
- * {@code minHeight} is what the layout pins, so the setting goes on {@code minimumHeight} — the
- * row keeps {@code wrap_content} and a taller label still grows it rather than being clipped.
- */
- private void applyRowHeight(android.view.View card, int rowHeightDp) {
- android.view.View row = findRowContainer(card);
- if (row == null) return;
- float density = card.getResources().getDisplayMetrics().density;
- int target = (int) (rowHeightDp * density);
- // Never shrink below what the content already needs.
- if (row.getMinimumHeight() != target) row.setMinimumHeight(target);
- }
-
- /**
- * The row container inside a card, located through the switch (M6).
- *
- *
Returns the switch's parent, which is the horizontal row holding the icon, the text column
- * and the controls. Null when the card has no switch (an action row), in which case there is
- * nothing to size.
- */
- private android.view.View findRowContainer(android.view.View card) {
- android.view.View switchView = card.findViewById(R.id.mod_card_switch);
- if (switchView == null) return null;
- android.view.ViewParent parent = switchView.getParent();
- return parent instanceof android.view.View ? (android.view.View) parent : null;
- }
-
- /** The adapter holds no Context; the attached RecyclerView supplies one. */
- private android.content.Context holderContext() {
- return recycler != null ? recycler.getContext() : null;
- }
-
- private void updateCardState(ModViewHolder holder, boolean enabled, boolean available) {
- // Unavailable wins over the enabled/disabled tint: a greyed card is the whole signal.
- holder.itemView.setAlpha(available ? 1f : 0.55f);
- holder.itemView.setEnabled(available);
- holder.itemView.setClickable(available);
- holder.icon.setAlpha(!available ? 0.5f : (enabled ? 1f : 0.8f));
- holder.name.setTextColor(!available ? theme.textTertiary()
- : (enabled ? theme.textPrimary() : ModMenuPalette.blend(
- theme.textPrimary(), theme.textSecondary(), 0.4f)));
-
- if (holder.itemView instanceof androidx.cardview.widget.CardView) {
- androidx.cardview.widget.CardView cv = (androidx.cardview.widget.CardView) holder.itemView;
-
- // M6: the corner-radius slider and the density both reach the row here. The radius was
- // stored and displayed but the card's radius came from the layout's fixed dp, so the
- // slider moved nothing.
- float density = holder.itemView.getResources().getDisplayMetrics().density;
- cv.setRadius(theme.cornerRadiusDp() * density);
- applyRowHeight(cv, theme.rowHeightDp());
-
- if (!available) {
- cv.setCardBackgroundColor(theme.cardSurface());
- cv.setCardElevation(0f);
- } else if (enabled) {
- cv.setCardBackgroundColor(theme.enabledCardColor());
- cv.setCardElevation(theme.enabledElevation());
- } else {
- cv.setCardBackgroundColor(theme.disabledCardColor());
- cv.setCardElevation(theme.disabledElevation());
- }
- }
- }
-
-
- @Override
- public long getItemId(int position) {
- String key = items.get(position).stableKey();
- long hash = 0xcbf29ce484222325L;
- for (int i = 0; i < key.length(); i++) {
- hash ^= key.charAt(i);
- hash *= 0x100000001b3L;
- }
- return hash;
- }
-
- @Override
- public int getItemCount() {
- return items.size();
- }
-
- private static class MenuItem {
- /** A sentinel id for the synthetic {@code ACTIONS} header, which has no group. */
- static final String ACTIONS_HEADER_ID = "__actions__";
- final String groupId;
- final String groupName;
- final UnifiedMod mod;
- final boolean showGroupLabel;
- /** The action this row fires, or null for a module/header row (M3). */
- final ModMenuAction action;
-
- private MenuItem(String groupId, String groupName, UnifiedMod mod, boolean showGroupLabel,
- ModMenuAction action) {
- this.groupId = groupId;
- this.groupName = groupName;
- this.mod = mod;
- this.showGroupLabel = showGroupLabel;
- this.action = action;
- }
-
- static MenuItem group(String groupId, String groupName) {
- return new MenuItem(groupId, groupName, null, false, null);
- }
-
- static MenuItem mod(UnifiedMod mod, boolean showGroupLabel) {
- return new MenuItem(null, null, mod, showGroupLabel, null);
- }
-
- /** The synthetic section header above the action rows (M3). */
- static MenuItem actionsHeader() {
- return new MenuItem(ACTIONS_HEADER_ID, null, null, false, null);
- }
-
- static MenuItem action(ModMenuAction action) {
- return new MenuItem(null, null, null, false, action);
- }
-
- boolean isGroup() {
- // A section header (or the ACTIONS header) is a group; an action row is not.
- return mod == null && action == null;
- }
-
- boolean isActionsHeader() {
- return mod == null && action == null && ACTIONS_HEADER_ID.equals(groupId);
- }
-
- boolean isAction() {
- return action != null;
- }
-
- String stableKey() {
- if (isAction()) {
- return action.getStableKey();
- }
- if (isActionsHeader()) {
- return "header:" + ACTIONS_HEADER_ID;
- }
- return isGroup() ? "group:" + groupId : "mod:" + mod.getStableKey();
- }
- }
-
- static class GroupViewHolder extends RecyclerView.ViewHolder {
- final View bar;
- final TextView title;
-
- GroupViewHolder(View itemView, View bar, TextView title) {
- super(itemView);
- this.bar = bar;
- this.title = title;
- }
- }
-
- static class ModViewHolder extends RecyclerView.ViewHolder {
- ImageView icon;
- TextView name;
- TextView statusText;
- TextView groupText;
- ImageButton favoriteBtn;
- ImageButton configBtn;
- TextView unavailableText;
- TextView reasonText;
- View accentBar;
- View statusDot;
- ImageView chevron;
- ModMenuSwitchView switchView;
-
- ModViewHolder(View itemView) {
- super(itemView);
- icon = itemView.findViewById(R.id.mod_card_icon);
- name = itemView.findViewById(R.id.mod_card_name);
- groupText = itemView.findViewById(R.id.mod_card_group);
- favoriteBtn = itemView.findViewById(R.id.mod_card_favorite);
- configBtn = itemView.findViewById(R.id.mod_card_config);
- unavailableText = itemView.findViewById(R.id.mod_card_unavailable);
- reasonText = itemView.findViewById(R.id.mod_card_reason);
- accentBar = itemView.findViewById(R.id.mod_card_accent);
- statusDot = itemView.findViewById(R.id.mod_card_status_dot);
- chevron = itemView.findViewById(R.id.mod_card_chevron);
- switchView = itemView.findViewById(R.id.mod_card_switch);
- // 27dp config and favourite buttons in a card. Enlarging the draw would push the row
- // apart, so the touch area is grown instead and the visuals stay as designed.
- TouchTargetHelper.expand(favoriteBtn);
- TouchTargetHelper.expand(configBtn);
- }
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuButton.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuButton.java
index 74a51a39..0002ad16 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuButton.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuButton.java
@@ -20,6 +20,7 @@
import org.chimeramc.client.R;
import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
import org.chimeramc.client.preloader.PreloaderInput;
+import org.chimeramc.client.launcher.core.mods.inbuilt.overlay.InGameMenuController;
import org.chimeramc.client.util.MainThread;
public class ModMenuButton {
@@ -42,10 +43,6 @@ public class ModMenuButton {
private static final long TAP_TIMEOUT = 200;
private static final float DRAG_THRESHOLD = 10f;
- /** The one Mod Menu (U4). It used to be two windows; the bind and the launcher open this. */
- private ModMenuOverlay menuOverlay;
- /** The radial mod wheel (section 1), opened from the same bind. */
- private org.chimeramc.client.core.mods.inbuilt.overlay.RadialMenuOverlay radialMenuOverlay;
public ModMenuButton(Activity activity) {
this.activity = activity;
@@ -228,16 +225,9 @@ private void onButtonClick() {
return;
}
- if (menuOverlay == null) {
- menuOverlay = new ModMenuOverlay(activity);
- menuOverlay.setCallback(new ModMenuCallback());
- }
-
- if (menuOverlay.isShowing()) {
- menuOverlay.hide();
- } else {
- menuOverlay.show();
- }
+ InGameMenuController menu = InGameMenuController.install(activity);
+ if (menu == null) return;
+ menu.toggle();
}
@@ -253,10 +243,7 @@ public void hide() {
// Invalidated before the isShowing check so a hide during the attach delay still wins.
showGate.invalidate();
handler.removeCallbacksAndMessages(null);
- if (menuOverlay != null) {
- menuOverlay.hide();
- menuOverlay = null;
- }
+ hideMenu();
if (!isShowing || buttonView == null) return;
handler.post(() -> {
try {
@@ -287,136 +274,31 @@ public boolean isShowing() {
}
/**
- * The open menu, or null.
+ * Whether the native menu is showing.
*
- *
Exists so the game activity can hand controller input to the menu before the sweetloader may
- * swallow it — the overlay root is unfocusable, so the framework never routes a key or a stick
- * movement to it on its own.
+ *
The menu is drawn by the sweetloader on the game's GL context, so there is no Android view
+ * to inspect; the native side owns the open state.
*/
- public ModMenuOverlay getMenuOverlay() {
- return menuOverlay;
- }
-
public boolean isMenuShowing() {
- return (menuOverlay != null && menuOverlay.isShowing())
- || (radialMenuOverlay != null && radialMenuOverlay.isShowing());
+ InGameMenuController menu = InGameMenuController.getInstance();
+ return menu != null && menu.isMenuOpen();
}
/**
- * Opens or closes the Mod Menu from the bound input.
+ * Opens or closes the native in-game Mod Menu from the bound input.
*
- *
The bind opens the radial wheel, whose sectors route into the one Mod Menu (U4). The
- * launcher's Mods screen opens the same menu, so a module or a page cannot exist in one and be
- * missing from the other.
+ *
The bind opens the one menu (U4); the launcher's Mods screen opens the same one, so a
+ * module cannot exist in one and be missing from the other.
*/
public void toggleMenuFromBind() {
if (!isShowing) return;
- ensureRadialMenuOverlay();
- radialMenuOverlay.toggle();
- }
-
- /**
- * The radial wheel, created lazily and wired to this menu's destinations.
- *
- *
Exposed so the hold-to-open path can reach the wheel on the *press* (before any toggle has
- * happened) — creating it inside {@code toggleMenuFromBind} alone left the first press with a
- * null wheel, which is exactly the gesture hold-to-open needs to work.
- */
- public org.chimeramc.client.core.mods.inbuilt.overlay.RadialMenuOverlay
- ensureRadialMenuOverlay() {
- if (!isShowing) return null;
- if (radialMenuOverlay == null) {
- radialMenuOverlay =
- new org.chimeramc.client.core.mods.inbuilt.overlay.RadialMenuOverlay(activity);
- radialMenuOverlay.setDestinations(new org.chimeramc.client.core.mods.inbuilt.overlay
- .RadialMenuOverlay.Destinations() {
- @Override
- public void openMods() {
- openMenuFiltered(
- org.chimeramc.client.core.mods.inbuilt.overlay.ModuleFilter.ALL);
- }
-
- @Override
- public void openHud() {
- // The HUD sector is a filter over the one module list, so it lands on the same
- // rows the HUD chip shows rather than a second, hand-built list.
- openMenuFiltered(
- org.chimeramc.client.core.mods.inbuilt.overlay.ModuleFilter.HUD);
- }
-
- @Override
- public void openCustomize() {
- new org.chimeramc.client.core.mods.inbuilt.overlay.CustomizeChooser(
- activity,
- () -> openMenuSection(org.chimeramc.client.core.mods.inbuilt.overlay
- .ModMenuPage.SETTINGS),
- () -> openMenuSection(org.chimeramc.client.core.mods.inbuilt.overlay
- .ModMenuPage.COSMETICS)).show();
- }
-
- @Override
- public void openSettings() {
- openMenuSection(
- org.chimeramc.client.core.mods.inbuilt.overlay.ModMenuPage.SETTINGS);
- }
- });
- }
- return radialMenuOverlay;
- }
-
- /**
- * Opens the one Mod Menu on a specific page, creating it if needed.
- *
- *
The radial wheel's sectors route here, so a sector opens the same menu the launcher and
- * the bind do, on the page it names.
- */
- private void openMenuSection(org.chimeramc.client.core.mods.inbuilt.overlay.ModMenuPage page) {
- if (menuOverlay == null) {
- menuOverlay = new ModMenuOverlay(activity);
- menuOverlay.setCallback(new ModMenuCallback());
- }
- menuOverlay.showPage(page);
- }
-
- /**
- * Opens the one Mod Menu's module list already filtered to a section.
- *
- *
The wheel's MODS and HUD sectors route here, so the wheel lands on the same filtered rows
- * the filter chip shows. The menu is created lazily and the filter is applied on the first
- * frame, so a cold open and an already-open menu behave identically.
- */
- private void openMenuFiltered(
- org.chimeramc.client.core.mods.inbuilt.overlay.ModuleFilter filter) {
- if (menuOverlay == null) {
- menuOverlay = new ModMenuOverlay(activity);
- menuOverlay.setCallback(new ModMenuCallback());
- }
- menuOverlay.showModulesFiltered(filter);
- }
-
- /** The callback the overlay calls back into, kept in one place so both paths share it. */
- private final class ModMenuCallback implements ModMenuOverlay.ModMenuCallback {
- @Override
- public void onModToggled(String modId, boolean enabled) {
- }
-
- @Override
- public void onButtonOpacityChanged(int opacity) {
- applyButtonOpacity();
- }
- }
-
- /** The open radial wheel, or null. */
- public org.chimeramc.client.core.mods.inbuilt.overlay.RadialMenuOverlay getRadialMenuOverlay() {
- return radialMenuOverlay;
+ InGameMenuController menu = InGameMenuController.install(activity);
+ if (menu != null) menu.toggle();
}
+ /** Closes the native menu if it is open. */
public void hideMenu() {
- if (menuOverlay != null && menuOverlay.isShowing()) {
- menuOverlay.hide();
- }
- if (radialMenuOverlay != null && radialMenuOverlay.isShowing()) {
- radialMenuOverlay.hide();
- }
+ InGameMenuController menu = InGameMenuController.getInstance();
+ if (menu != null) menu.hide();
}
}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuControllerSection.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuControllerSection.java
deleted file mode 100644
index e4c83a94..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuControllerSection.java
+++ /dev/null
@@ -1,337 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import org.chimeramc.client.util.AppLog;
-
-import android.app.Activity;
-import android.content.res.ColorStateList;
-import android.graphics.Typeface;
-import android.graphics.drawable.GradientDrawable;
-import android.view.Gravity;
-import android.view.InputDevice;
-import android.view.KeyEvent;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.FrameLayout;
-import android.widget.LinearLayout;
-import android.widget.SeekBar;
-import android.widget.TextView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
-import org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay;
-import org.chimeramc.client.launcher.controller.ControllerConnectionMonitor;
-import org.chimeramc.client.launcher.controller.ControllerInputProcessor;
-import org.chimeramc.client.launcher.controller.ControllerProfile;
-import org.chimeramc.client.launcher.controller.ControllerProfileList;
-import org.chimeramc.client.launcher.controller.ControllerProfileManager;
-import org.chimeramc.client.launcher.controller.ControllerType;
-import org.chimeramc.client.launcher.ui.views.Controller3DView;
-import org.chimeramc.client.ui.animation.DynamicAnim;
-import org.chimeramc.client.ui.components.UiTokens;
-
-import java.util.List;
-
-/**
- * The Controller page of the Mod Menu (a Supporter page).
- *
- *
It was the VIP window's own Controller tab. Now that there is one menu (U4) it is a page of
- * that menu, so it is reachable from the launcher's Mods screen as well as from the in-game bind.
- * It mirrors the launcher's Controller settings — profiles, dead zone, sensitivity, rumble — and
- * opens the same {@link ControllerProfileEditor} the launcher uses, so there is one editor rather
- * than two that drift.
- *
- *
Every step is isolated through {@link ModMenuGuard}: this page is built over a running game,
- * and a bad profile or illustration lookup must land in the diagnostics report instead of taking
- * the session down.
- */
-final class ModMenuControllerSection {
-
- private final Activity activity;
- private final FrameLayout host;
- private final int accent;
-
- private Controller3DView illustration;
- private TextView typeText;
- private TextView statusText;
- private LinearLayout profileRow;
- private LinearLayout sliderRow;
- private View emptyView;
- private TextView fullEditorButton;
- private ControllerProfileEditor profileEditor;
- private ControllerProfileManager profileManager;
- private Runnable onSectionChanged;
-
- ModMenuControllerSection(Activity activity, FrameLayout host, int accent) {
- this.activity = activity;
- this.host = host;
- this.accent = accent;
- }
-
- /** Called when a change (a new active profile) should repaint the whole page. */
- void setOnSectionChanged(Runnable callback) {
- this.onSectionChanged = callback;
- }
-
- void build() {
- if (host == null) return;
- host.removeAllViews();
- View view = LayoutInflater.from(activity)
- .inflate(R.layout.vip_controller_section, host, false);
- host.addView(view, new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
-
- illustration = view.findViewById(R.id.vip_controller_illustration);
- typeText = view.findViewById(R.id.vip_controller_type);
- statusText = view.findViewById(R.id.vip_controller_status);
- profileRow = view.findViewById(R.id.vip_controller_profiles);
- sliderRow = view.findViewById(R.id.vip_controller_sliders);
- emptyView = view.findViewById(R.id.vip_controller_empty);
- fullEditorButton = null;
- profileEditor = null;
- if (illustration != null) illustration.setAccentColor(accent);
- profileManager = new ControllerProfileManager(activity);
- }
-
- void refresh() {
- if (host == null || illustration == null || profileManager == null) return;
- InputDevice gamepad = firstGamepad();
- boolean hasPad = gamepad != null;
- if (emptyView != null) emptyView.setVisibility(hasPad ? View.GONE : View.VISIBLE);
- illustration.setVisibility(hasPad ? View.VISIBLE : View.GONE);
-
- ControllerType type = hasPad ? ControllerType.from(gamepad) : ControllerType.XBOX;
- if (type == null) type = ControllerType.XBOX;
- final ControllerType resolved = type;
-
- ModMenuGuard.run("controller.setType", () -> {
- illustration.setType(resolved);
- if (typeText != null) typeText.setText(resolved.getDisplayName());
- });
- final ControllerType sectionType = resolved;
- ModMenuGuard.run("controller.status", () -> {
- List profiles = profileManager.getProfiles(sectionType);
- int activeSlot = profileManager.getActiveSlot(sectionType);
- String name = profiles.isEmpty() ? "Default"
- : profiles.get(Math.min(activeSlot, profiles.size() - 1)).getName();
- if (statusText != null) {
- statusText.setText(activity.getString(
- R.string.vip_controller_active_profile) + ": " + name);
- }
- });
- ModMenuGuard.run("controller.bindGlow", () -> {
- InbuiltModManager modManager = InbuiltModManager.getInstance(activity);
- illustration.clearConfirmed();
- if (modManager != null) {
- String region = illustration.regionIdForKey(modManager.getModMenuControllerBind());
- if (region != null) illustration.setRegionConfirmed(region, true);
- }
- });
- ModMenuGuard.run("controller.chips", () -> {
- List profiles = profileManager.getProfiles(sectionType);
- rebuildProfileChips(sectionType, profiles, profileManager.getActiveSlot(sectionType));
- });
- ModMenuGuard.run("controller.sliders", () -> {
- List profiles = profileManager.getProfiles(sectionType);
- rebuildSliders(sectionType, profiles, profileManager.getActiveSlot(sectionType));
- });
- ModMenuGuard.run("controller.fullEditorButton", () -> ensureFullEditorButton(sectionType));
- }
-
- /** The raw pad button is echoed onto the illustration, so the page shows the live input. */
- boolean handleHardwareKey(int keyCode) {
- if (illustration == null) return false;
- illustration.handleKeyEvent(keyCode, true);
- return false;
- }
-
- boolean handleHardwareMotion(android.view.MotionEvent event) {
- if (illustration == null) return false;
- illustration.handleMotionEvent(event);
- return false;
- }
-
- boolean isCapturingModifier() {
- return profileEditor != null && profileEditor.isCapturingModifier();
- }
-
- boolean handleCaptureKey(int keyCode) {
- return profileEditor != null && profileEditor.handleHardwareKey(keyCode);
- }
-
- private void ensureFullEditorButton(final ControllerType type) {
- if (sliderRow == null || fullEditorButton != null) return;
- TextView button = new TextView(activity);
- button.setText(R.string.controller_edit);
- button.setTextSize(13);
- button.setTextColor(0xFFFFFFFF);
- int pad = (int) (16 * density());
- button.setPadding(pad, pad / 2, pad, pad / 2);
- button.setGravity(Gravity.CENTER);
- GradientDrawable bg = new GradientDrawable();
- bg.setShape(GradientDrawable.RECTANGLE);
- bg.setCornerRadius(12 * density());
- bg.setColor(accent);
- button.setBackground(bg);
- DynamicAnim.applyPressScale(button);
- button.setOnClickListener(v -> {
- boolean ok = ModMenuGuard.run("openFullControllerEditor", () -> {
- if (profileEditor == null) {
- profileEditor = new ControllerProfileEditor(activity, profileManager);
- }
- profileEditor.show(type, saved -> ModMenuGuard.run(
- "refreshControllerSection(after edit)", this::refresh));
- });
- if (!ok) {
- try {
- android.widget.Toast.makeText(activity, R.string.vip_controller_step_failed,
- android.widget.Toast.LENGTH_SHORT).show();
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuControllerSection:190", "Ignored a failure at ModMenuControllerSection:190", ignored);
- }
- }
- });
- sliderRow.addView(button);
- fullEditorButton = button;
- }
-
- private void rebuildProfileChips(final ControllerType type, List profiles,
- int activeSlot) {
- if (profileRow == null) return;
- profileRow.removeAllViews();
- for (int i = 0; i < profiles.size(); i++) {
- final int slot = i;
- TextView chip = new TextView(activity);
- chip.setText(profiles.get(i).getName());
- chip.setTextSize(12);
- chip.setSingleLine(true);
- int pad = (int) (14 * density());
- chip.setPadding(pad, pad / 2, pad, pad / 2);
- boolean selected = i == activeSlot;
- chip.setTextColor(selected ? 0xFFFFFFFF : UiTokens.TEXT_SECONDARY);
- GradientDrawable bg = new GradientDrawable();
- bg.setShape(GradientDrawable.RECTANGLE);
- bg.setCornerRadius(12 * density());
- bg.setColor(selected ? accent : UiTokens.BG_SURFACE_HOVER);
- chip.setBackground(bg);
- LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.rightMargin = (int) (8 * density());
- params.bottomMargin = (int) (8 * density());
- chip.setLayoutParams(params);
- DynamicAnim.applyPressScale(chip);
- chip.setOnClickListener(v -> {
- profileManager.setActiveSlot(type, slot);
- ControllerInputProcessor.setActiveProfile(type, profileManager.getActiveProfile(type));
- refresh();
- if (onSectionChanged != null) onSectionChanged.run();
- });
- profileRow.addView(chip);
- }
- }
-
- private void rebuildSliders(final ControllerType type, List profiles,
- int activeSlot) {
- if (sliderRow == null || profiles.isEmpty()) return;
- sliderRow.removeAllViews();
- final ControllerProfile profile = profiles.get(Math.min(activeSlot, profiles.size() - 1));
- addSlider(sliderRow, activity.getString(R.string.vip_dead_zone),
- (int) (profile.getRightDeadZone() * 100), 0, 60, value -> {
- profile.setRightDeadZone(value / 100f);
- profile.setLeftDeadZone(value / 100f);
- persistProfile(type, activeSlot, profile);
- });
- addSlider(sliderRow, activity.getString(R.string.vip_sensitivity),
- (int) (profile.getRightStickSensitivity() * 100), 25, 300, value -> {
- profile.setRightStickSensitivity(value / 100f);
- profile.setLeftStickSensitivity(value / 100f);
- persistProfile(type, activeSlot, profile);
- });
- addSlider(sliderRow, activity.getString(R.string.vip_rumble),
- profile.getRumbleStrength(), 0, 100, value -> {
- profile.setRumbleStrength(value);
- profile.setVibrationEnabled(value > 0);
- persistProfile(type, activeSlot, profile);
- });
- }
-
- private void persistProfile(ControllerType type, int slot, ControllerProfile profile) {
- if (profileManager == null) return;
- // Save the edited object, not a fresh read: getProfiles deserialises new objects each call,
- // so saving that list wrote the unmodified profiles back and a slider change was applied to
- // the running processor but never persisted.
- List profiles =
- ControllerProfileList.replaceAt(profileManager.getProfiles(type), slot, profile);
- profileManager.saveProfiles(type, profiles);
- profileManager.setActiveSlot(type, slot);
- ControllerInputProcessor.setActiveProfile(type, profile);
- ControllerInputProcessor.reload(activity);
- }
-
- private interface ValueListener {
- void onValue(int value);
- }
-
- private void addSlider(LinearLayout parent, String label, int initial, int min, int max,
- final ValueListener listener) {
- LinearLayout row = new LinearLayout(activity);
- row.setOrientation(LinearLayout.VERTICAL);
- LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- rowParams.bottomMargin = (int) (14 * density());
- row.setLayoutParams(rowParams);
-
- TextView header = new TextView(activity);
- header.setText(label);
- header.setTextColor(UiTokens.TEXT_SECONDARY);
- header.setTextSize(12);
- row.addView(header);
-
- final TextView value = new TextView(activity);
- value.setText(String.valueOf(initial));
- value.setTextColor(accent);
- value.setTextSize(12);
- value.setTypeface(null, Typeface.BOLD);
- value.setGravity(Gravity.END);
- row.addView(value);
-
- SeekBar bar = new SeekBar(activity);
- bar.setMax(Math.max(1, max - min));
- bar.setProgress(Math.max(0, Math.min(max - min, initial - min)));
- bar.setProgressTintList(ColorStateList.valueOf(accent));
- bar.setThumbTintList(ColorStateList.valueOf(accent));
- bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- int actual = min + progress;
- value.setText(String.valueOf(actual));
- if (fromUser) listener.onValue(actual);
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
- }
- });
- row.addView(bar);
- parent.addView(row);
- }
-
- private InputDevice firstGamepad() {
- android.hardware.input.InputManager im = (android.hardware.input.InputManager)
- activity.getSystemService(Activity.INPUT_SERVICE);
- if (im == null) return null;
- for (int id : im.getInputDeviceIds()) {
- InputDevice device = im.getInputDevice(id);
- if (ControllerConnectionMonitor.isGamepad(device)) return device;
- }
- return null;
- }
-
- private float density() {
- return activity.getResources().getDisplayMetrics().density;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuGuard.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuGuard.java
deleted file mode 100644
index 19c013c6..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuGuard.java
+++ /dev/null
@@ -1,126 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Deque;
-import java.util.List;
-
-/**
- * Containment for the mod-menu UI: wraps a step so one bad widget cannot kill the game process.
- *
- * The controller settings screens (the CONTROLLER tab and the "Full settings" dialog) build a
- * large tree with {@code Controller3DView}, curve previews, layer rows and spinners. If any one of
- * those throws while the in-game overlay window is attached, an uncaught exception tears down the
- * game. This class is the seatbelt, not the fix: each step is wrapped, the failure is recorded
- * (class, message, first N stack frames) for the diagnostics report, and the caller shows a toast and
- * carries on.
- *
- *
Pure and Android-free so the recording rule (bounded, newest-first, truncated frames) is
- * unit-tested without a device.
- */
-public final class ModMenuGuard {
-
- /** How many stack frames are kept per failure, enough to locate the throw site. */
- public static final int MAX_FRAMES = 12;
- /** How many failures are retained, so a loop cannot grow the buffer without bound. */
- public static final int MAX_ENTRIES = 32;
-
- /** One recorded failure. */
- public static final class Failure {
- public final String label;
- public final String throwableClass;
- public final String message;
- public final List frames;
- public final long timeMs;
-
- Failure(String label, String throwableClass, String message, List frames,
- long timeMs) {
- this.label = label == null ? "" : label;
- this.throwableClass = throwableClass == null ? "" : throwableClass;
- this.message = message == null ? "" : message;
- this.frames = frames == null ? Collections.emptyList() : frames;
- this.timeMs = timeMs;
- }
-
- /** A compact multi-line description for the diagnostics report. */
- public String describe() {
- StringBuilder b = new StringBuilder();
- b.append(label).append(": ").append(throwableClass);
- if (!message.isEmpty()) b.append(": ").append(message);
- for (String frame : frames) {
- b.append("\n at ").append(frame);
- }
- return b.toString();
- }
- }
-
- private static final Deque FAILURES = new ArrayDeque<>();
-
- private ModMenuGuard() {
- }
-
- /**
- * Runs {@code body}, catching any {@link Throwable}. Returns true when the body completed
- * without throwing; false when it failed (and the failure was recorded).
- */
- public static boolean run(String label, Runnable body) {
- if (body == null) return true;
- try {
- body.run();
- return true;
- } catch (Throwable t) {
- record(label, t, System.currentTimeMillis());
- return false;
- }
- }
-
- /** Records a failure with its class, message and first {@link #MAX_FRAMES} stack frames. */
- public static void record(String label, Throwable t, long timeMs) {
- if (t == null) return;
- List frames = new ArrayList<>();
- StackTraceElement[] trace = t.getStackTrace();
- for (int i = 0; i < trace.length && i < MAX_FRAMES; i++) {
- frames.add(trace[i].toString());
- }
- synchronized (FAILURES) {
- FAILURES.addFirst(new Failure(label, t.getClass().getName(), t.getMessage(),
- frames, timeMs));
- while (FAILURES.size() > MAX_ENTRIES) {
- FAILURES.removeLast();
- }
- }
- }
-
- /** The recorded failures, newest first. */
- public static List failures() {
- synchronized (FAILURES) {
- return new ArrayList<>(FAILURES);
- }
- }
-
- /** True when at least one menu build step has failed this session. */
- public static boolean hasFailures() {
- synchronized (FAILURES) {
- return !FAILURES.isEmpty();
- }
- }
-
- /** Clears the buffer (session teardown). */
- public static void clear() {
- synchronized (FAILURES) {
- FAILURES.clear();
- }
- }
-
- /** The failures formatted for the diagnostics report, or a "none" line. */
- public static String format() {
- List list = failures();
- if (list.isEmpty()) return " none\n";
- StringBuilder sb = new StringBuilder();
- for (Failure f : list) {
- sb.append(" ").append(f.describe()).append('\n');
- }
- return sb.toString();
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuKeyboardSection.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuKeyboardSection.java
deleted file mode 100644
index 60afe2a8..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuKeyboardSection.java
+++ /dev/null
@@ -1,253 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.app.Activity;
-import android.graphics.Typeface;
-import android.graphics.drawable.GradientDrawable;
-import android.os.Handler;
-import android.os.Looper;
-import android.view.Gravity;
-import android.view.KeyEvent;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.FrameLayout;
-import android.widget.LinearLayout;
-import android.widget.SeekBar;
-import android.widget.TextView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
-import org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay;
-import org.chimeramc.client.launcher.ui.views.KeyboardIllustrationView;
-import org.chimeramc.client.ui.animation.DynamicAnim;
-import org.chimeramc.client.ui.components.UiTokens;
-import org.chimeramc.client.util.MainThread;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * The Keyboard page of the Mod Menu (a Supporter page).
- *
- * It was the VIP window's Keyboard tab. With one menu (U4) it is a page of that menu, so the
- * drawn board, the rebindable actions and the pointer settings are reachable from the launcher's
- * Mods screen too. The bind capture goes through {@link MenuBindRelay}, the one sink the game
- * activity feeds at the top of {@code dispatchKeyEvent}, so a captured press is never swallowed by
- * the sweetloader first.
- */
-final class ModMenuKeyboardSection {
-
- private static final long KEY_CAPTURE_TIMEOUT_MS = 6000L;
-
- private final Activity activity;
- private final FrameLayout host;
- private final int accent;
- private final Handler handler = MainThread.handler();
-
- private KeyboardIllustrationView illustration;
- private LinearLayout keysRow;
- private LinearLayout settingsRow;
-
- ModMenuKeyboardSection(Activity activity, FrameLayout host, int accent) {
- this.activity = activity;
- this.host = host;
- this.accent = accent;
- }
-
- void build() {
- if (host == null) return;
- host.removeAllViews();
- View view = LayoutInflater.from(activity)
- .inflate(R.layout.vip_keyboard_section, host, false);
- host.addView(view, new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
- illustration = view.findViewById(R.id.vip_keyboard_illustration);
- keysRow = view.findViewById(R.id.vip_keyboard_keys);
- settingsRow = view.findViewById(R.id.vip_keyboard_settings);
- if (illustration != null) illustration.setAccentColor(accent);
- }
-
- void refresh() {
- if (keysRow == null) return;
- InbuiltModManager manager = InbuiltModManager.getInstance(activity);
- keysRow.removeAllViews();
- if (manager != null) {
- for (KbmAction action : actions()) {
- addKeybindRow(action, manager, action.getter.get(manager));
- }
- }
- rebuildSettings();
- }
-
- private void rebuildSettings() {
- if (settingsRow == null) return;
- settingsRow.removeAllViews();
- InbuiltModManager manager = InbuiltModManager.getInstance(activity);
- if (manager != null) {
- addSlider(activity.getString(R.string.mod_config_cursor_sensitivity_percent),
- manager.getCursorSensitivity(), 10, 300, manager::setCursorSensitivity);
- }
- }
-
- private void addKeybindRow(final KbmAction action, final InbuiltModManager manager, int code) {
- LinearLayout row = new LinearLayout(activity);
- row.setOrientation(LinearLayout.HORIZONTAL);
- row.setGravity(Gravity.CENTER_VERTICAL);
- row.setBackground(activity.getDrawable(R.drawable.bg_vip_surface));
- int pad = (int) (14 * density());
- row.setPadding(pad, pad, pad, pad);
- LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.bottomMargin = (int) (8 * density());
- row.setLayoutParams(params);
-
- TextView label = new TextView(activity);
- label.setText(action.label);
- label.setTextColor(UiTokens.TEXT_PRIMARY);
- label.setTextSize(13);
- label.setLayoutParams(new LinearLayout.LayoutParams(
- 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
-
- final TextView value = new TextView(activity);
- value.setText(bindLabel(code));
- value.setTextColor(accent);
- value.setTextSize(12);
- value.setTypeface(null, Typeface.BOLD);
-
- row.addView(label);
- row.addView(value);
- DynamicAnim.applyPressScale(row);
- row.setOnClickListener(v -> openKeyCapture(action, value, manager));
- keysRow.addView(row);
- }
-
- private void openKeyCapture(final KbmAction action, final TextView value,
- final InbuiltModManager manager) {
- value.setText(R.string.mod_menu_bind_press_key);
- value.setTextColor(UiTokens.SUCCESS);
- MenuBindRelay.set(keyCode -> {
- MenuBindRelay.clear();
- if (keyCode != KeyEvent.KEYCODE_BACK) {
- action.setter.set(manager, keyCode);
- if (illustration != null) {
- illustration.setHighlightCode(keyCode);
- illustration.flashKey(keyCode);
- }
- }
- value.setText(bindLabel(action.getter.get(manager)));
- value.setTextColor(accent);
- });
- // A timeout releases the capture, so a stray press does not stay armed forever.
- handler.postDelayed(() -> {
- if (MenuBindRelay.isCapturing()) {
- MenuBindRelay.clear();
- value.setText(bindLabel(action.getter.get(manager)));
- value.setTextColor(accent);
- }
- }, KEY_CAPTURE_TIMEOUT_MS);
- }
-
- /**
- * Cancels the pending key-capture timeout.
- *
- *
The timeout releases a capture that was armed and then abandoned. Left pending past the
- * page switch it would run against a detached tree and reset a label the user can no longer see,
- * and it holds the section (and its activity) alive for its full duration.
- */
- void onHidden() {
- handler.removeCallbacksAndMessages(null);
- MenuBindRelay.clear();
- }
-
- private List actions() {
- List actions = new ArrayList<>();
- actions.add(new KbmAction("Open Mod Menu",
- InbuiltModManager::getModMenuKeybind, InbuiltModManager::setModMenuKeybind));
- actions.add(new KbmAction("Zoom",
- InbuiltModManager::getZoomKeybind, InbuiltModManager::setZoomKeybind));
- actions.add(new KbmAction("Auto sprint",
- InbuiltModManager::getAutoSprintKeybind, InbuiltModManager::setAutoSprintKeybind));
- return actions;
- }
-
- private void addSlider(String label, int initial, int min, int max, final ValueListener listener) {
- LinearLayout row = new LinearLayout(activity);
- row.setOrientation(LinearLayout.VERTICAL);
- LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- rowParams.bottomMargin = (int) (14 * density());
- row.setLayoutParams(rowParams);
-
- TextView header = new TextView(activity);
- header.setText(label);
- header.setTextColor(UiTokens.TEXT_SECONDARY);
- header.setTextSize(12);
- row.addView(header);
-
- final TextView value = new TextView(activity);
- value.setText(String.valueOf(initial));
- value.setTextColor(accent);
- value.setTextSize(12);
- value.setTypeface(null, Typeface.BOLD);
- value.setGravity(Gravity.END);
- row.addView(value);
-
- SeekBar bar = new SeekBar(activity);
- bar.setMax(Math.max(1, max - min));
- bar.setProgress(Math.max(0, Math.min(max - min, initial - min)));
- bar.setProgressTintList(android.content.res.ColorStateList.valueOf(accent));
- bar.setThumbTintList(android.content.res.ColorStateList.valueOf(accent));
- bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- int actual = min + progress;
- value.setText(String.valueOf(actual));
- if (fromUser) listener.onValue(actual);
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
- }
- });
- row.addView(bar);
- settingsRow.addView(row);
- }
-
- private String bindLabel(int keyCode) {
- if (keyCode == 0) return activity.getString(R.string.mod_menu_bind_none);
- String name = KeyEvent.keyCodeToString(keyCode);
- return name.startsWith("KEYCODE_") ? name.substring(8).replace('_', ' ') : name;
- }
-
- private float density() {
- return activity.getResources().getDisplayMetrics().density;
- }
-
- private interface ValueListener {
- void onValue(int value);
- }
-
- private interface IntGetter {
- int get(InbuiltModManager manager);
- }
-
- private interface IntSetter {
- void set(InbuiltModManager manager, int keyCode);
- }
-
- private static final class KbmAction {
- final String label;
- final IntGetter getter;
- final IntSetter setter;
-
- KbmAction(String label, IntGetter getter, IntSetter setter) {
- this.label = label;
- this.getter = getter;
- this.setter = setter;
- }
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuMotion.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuMotion.java
deleted file mode 100644
index e456b29c..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuMotion.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * The Mod Menu's motion vocabulary (M5, M6, U11).
- *
- * Two schemes, one switch: {@code full} is a spring, {@code reduced} is a short linear fade. The
- * durations live here rather than at the call sites so "Reduce motion" is one decision, and the OS
- * accessibility setting (which the settings model already folds in) cannot be overridden by a
- * per-widget literal.
- *
- *
Deliberately Android-free: the reduced easing is exposed as a plain interpolation function
- * rather than an {@code android.view.animation.Interpolator}, because that class is a stub
- * off-device and the rule has to be unit-testable without one. The view wraps it when it needs a
- * real {@code Interpolator}.
- */
-public final class ModMenuMotion {
-
- private ModMenuMotion() {
- }
-
- /** The full-motion switch duration: long enough for the spring's overshoot to read. */
- public static final long FULL_MS = 220L;
- /** The reduced-motion duration: a short linear fade, never an instant pop. */
- public static final long REDUCED_MS = 120L;
-
- /** The duration for the current scheme. */
- public static long durationMs(boolean reduced) {
- return reduced ? REDUCED_MS : FULL_MS;
- }
-
- /** The reduced-motion easing: linear, so there is no travel and no overshoot. */
- public static float reducedInterpolation(float input) {
- return Math.max(0f, Math.min(1f, input));
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuNavigation.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuNavigation.java
deleted file mode 100644
index 4407f6b9..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuNavigation.java
+++ /dev/null
@@ -1,162 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * D-pad/analogue navigation over the Mod Menu's module grid.
- *
- *
The overlay lives in a window the d-pad cannot focus (its root is deliberately unfocusable so
- * the platform stops painting a white focus wash over the game), so controller navigation is
- * computed here and applied to the list rather than left to the framework's focus system.
- *
- *
The grid is not a plain fixed-width table: a group header spans the whole row (see the
- * adapter's {@code SpanSizeLookup}), so a header occupies a row of its own. Column arithmetic that
- * ignored that would step onto a header and appear to skip a row, so the rows are laid out
- * explicitly here.
- *
- *
Pure and Android-free, so every edge -- moving off the end, a run of header rows, a menu with
- * nothing selectable -- is a JVM test rather than something found on a device mid-fight.
- */
-public final class ModMenuNavigation {
-
- /** No item is focused yet; the next directional press selects the first selectable item. */
- public static final int NONE = -1;
-
- public enum Direction {
- UP, DOWN, LEFT, RIGHT
- }
-
- private ModMenuNavigation() {
- }
-
- /**
- * The index a directional press should move to.
- *
- * @param current the focused index, or {@link #NONE}
- * @param columns the grid's column count (1 for a list)
- * @param fullRow one flag per item; true for a group header, which spans its own row
- * @param selectable one flag per item; a header or an unavailable module is not selectable
- * @return the new index, or {@link #NONE} when nothing can be focused
- */
- public static int move(int current, int columns, boolean[] fullRow, boolean[] selectable,
- Direction direction) {
- if (selectable == null || selectable.length == 0) return NONE;
- if (columns < 1) columns = 1;
- if (!isSelectable(selectable, current)) return firstSelectable(selectable);
-
- List rows = buildRows(columns, fullRow, selectable.length);
- int rowIndex = rowOf(rows, current);
- if (rowIndex < 0) return current;
-
- switch (direction) {
- case LEFT:
- return previousSelectable(selectable, current);
- case RIGHT:
- return nextSelectable(selectable, current);
- case UP:
- return rowStep(rows, selectable, rowIndex, current, -1);
- case DOWN:
- return rowStep(rows, selectable, rowIndex, current, 1);
- default:
- return current;
- }
- }
-
- /**
- * Lays the items out into rows.
- *
- * A full-row item becomes a row of its own; ordinary items fill rows of {@code columns}.
- */
- private static List buildRows(int columns, boolean[] fullRow, int length) {
- List rows = new ArrayList<>();
- int i = 0;
- while (i < length) {
- if (fullRow != null && i < fullRow.length && fullRow[i]) {
- rows.add(new int[]{i});
- i++;
- continue;
- }
- List row = new ArrayList<>();
- while (i < length && row.size() < columns
- && !(fullRow != null && i < fullRow.length && fullRow[i])) {
- row.add(i);
- i++;
- }
- int[] array = new int[row.size()];
- for (int j = 0; j < array.length; j++) array[j] = row.get(j);
- rows.add(array);
- }
- return rows;
- }
-
- private static int rowOf(List rows, int index) {
- for (int r = 0; r < rows.size(); r++) {
- for (int value : rows.get(r)) {
- if (value == index) return r;
- }
- }
- return -1;
- }
-
- /**
- * Moves to the nearest selectable item in the next row that has one.
- *
- * Rows with nothing selectable (a group header, a whole row of unavailable modules) are
- * skipped rather than swallowing the press, so the selection always lands somewhere new.
- */
- private static int rowStep(List rows, boolean[] selectable, int rowIndex, int current,
- int delta) {
- int column = columnOf(rows.get(rowIndex), current);
- for (int r = rowIndex + delta; r >= 0 && r < rows.size(); r += delta) {
- int found = nearestSelectable(rows.get(r), selectable, column);
- if (found != NONE) return found;
- }
- return current;
- }
-
- private static int nearestSelectable(int[] row, boolean[] selectable, int column) {
- if (row.length == 0) return NONE;
- int clamped = Math.min(column, row.length - 1);
- if (isSelectable(selectable, row[clamped])) return row[clamped];
- for (int offset = 1; offset < row.length; offset++) {
- int right = column + offset;
- if (right < row.length && isSelectable(selectable, row[right])) return row[right];
- int left = column - offset;
- if (left >= 0 && isSelectable(selectable, row[left])) return row[left];
- }
- return NONE;
- }
-
- private static int columnOf(int[] row, int index) {
- for (int i = 0; i < row.length; i++) {
- if (row[i] == index) return i;
- }
- return 0;
- }
-
- private static int previousSelectable(boolean[] selectable, int current) {
- for (int i = current - 1; i >= 0; i--) {
- if (selectable[i]) return i;
- }
- return current;
- }
-
- private static int nextSelectable(boolean[] selectable, int current) {
- for (int i = current + 1; i < selectable.length; i++) {
- if (selectable[i]) return i;
- }
- return current;
- }
-
- private static int firstSelectable(boolean[] selectable) {
- for (int i = 0; i < selectable.length; i++) {
- if (selectable[i]) return i;
- }
- return NONE;
- }
-
- private static boolean isSelectable(boolean[] selectable, int index) {
- return index >= 0 && index < selectable.length && selectable[index];
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuOrdering.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuOrdering.java
deleted file mode 100644
index 6f143867..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuOrdering.java
+++ /dev/null
@@ -1,119 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-/**
- * The Mod Menu's drag-to-reorder rule (M9).
- *
- * Pure and Android-free so the ordering can be unit-tested without a device: a saved order is a
- * list of module keys, and applying it must reorder each section while keeping every module that
- * the saved list does not mention in its shipped place.
- *
- *
The rule that matters is the fallback. A user's arrangement has to survive an update that adds
- * or removes a module, so an unknown key is dropped and an unlisted module keeps its shipped
- * position at the end of its own section rather than being sorted to the front by accident.
- */
-public final class ModMenuOrdering {
-
- private ModMenuOrdering() {
- }
-
- /**
- * Reorders one section's keys by a saved order.
- *
- * @param shipped the section's keys in their shipped order
- * @param saved the user's saved order, or null/empty for "never reordered"
- * @return the reordered keys, always containing every shipped key exactly once
- */
- public static List apply(List shipped, List saved) {
- List result = new ArrayList<>();
- if (shipped == null || shipped.isEmpty()) return result;
- if (saved == null || saved.isEmpty()) {
- result.addAll(shipped);
- return result;
- }
- Map rank = new HashMap<>();
- int next = 0;
- for (String key : saved) {
- if (key == null || rank.containsKey(key)) continue;
- rank.put(key, next++);
- }
- // A stable sort keeps the shipped relative order for everything the saved order does not
- // mention, which is exactly the "keeps its shipped place" rule.
- List ranked = new ArrayList<>();
- List unranked = new ArrayList<>();
- Set seen = new LinkedHashSet<>();
- for (String key : shipped) {
- if (key == null || !seen.add(key)) continue;
- if (rank.containsKey(key)) {
- ranked.add(key);
- } else {
- unranked.add(key);
- }
- }
- ranked.sort((a, b) -> Integer.compare(rank.get(a), rank.get(b)));
- result.addAll(ranked);
- result.addAll(unranked);
- return result;
- }
-
- /**
- * Sorts a section's keys alphabetically by their display name.
- *
- * Modules used to appear in whatever order the catalogue registered them, so a feature was
- * found by remembering where it sat rather than by its name. This is the shipped order's first
- * pass: a stable, case-insensitive sort by the module's display name, with a module whose name
- * cannot be resolved (or is missing) after every real one rather than dropped. Duplicates and
- * nulls are removed, so a caller can feed the result straight into {@link #apply}.
- *
- *
It is the base order, not the final one: a user's saved arrangement is applied on
- * top through {@link #apply}, so an alphabetical default and a deliberate custom order coexist.
- *
- * @param keys the section's keys
- * @param nameOf resolves a key to its display name, or null when unknown
- * @return the keys sorted by name; empty for a null or empty input
- */
- public static List alphabetically(List keys,
- java.util.function.Function nameOf) {
- List unique = new ArrayList<>();
- if (keys == null || keys.isEmpty()) return unique;
- Set seen = new LinkedHashSet<>();
- for (String key : keys) {
- if (key == null || !seen.add(key)) continue;
- unique.add(key);
- }
- // A missing name sorts after every real name, so an unnamed row never floats to the top.
- unique.sort((a, b) -> {
- String na = nameOf == null ? null : nameOf.apply(a);
- String nb = nameOf == null ? null : nameOf.apply(b);
- boolean ma = na == null || na.isEmpty();
- boolean mb = nb == null || nb.isEmpty();
- if (ma != mb) return ma ? 1 : -1;
- if (ma) return 0;
- int byName = na.compareToIgnoreCase(nb);
- // A tie on the name falls back to the key, so the order is total and stable.
- return byName != 0 ? byName : a.compareTo(b);
- });
- return unique;
- }
-
- /**
- * Moves one key within a list, returning the new order.
- *
- * An out-of-range move returns the list unchanged, so a stale drag index cannot silently
- * drop or duplicate an entry.
- */
- public static List move(List keys, int from, int to) {
- List result = new ArrayList<>();
- if (keys != null) result.addAll(keys);
- if (from < 0 || to < 0 || from >= result.size() || to >= result.size()) return result;
- String moved = result.remove(from);
- result.add(to, moved);
- return result;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuOverlay.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuOverlay.java
deleted file mode 100644
index 21db4ecd..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuOverlay.java
+++ /dev/null
@@ -1,3123 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import org.chimeramc.client.util.AppLog;
-
-import org.chimeramc.client.util.ApiLevels;
-
-import android.app.Activity;
-import android.content.res.ColorStateList;
-import android.graphics.PixelFormat;
-import android.graphics.drawable.Drawable;
-import android.util.TypedValue;
-import android.os.Handler;
-import android.os.Looper;
-import android.text.Editable;
-import android.text.TextWatcher;
-import android.view.Gravity;
-import android.view.LayoutInflater;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.WindowManager;
-import android.widget.EditText;
-import android.widget.Toast;
-import android.widget.FrameLayout;
-import android.widget.ImageButton;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
-import android.widget.PopupMenu;
-import android.widget.SeekBar;
-import android.widget.Switch;
-import android.widget.TextView;
-
-import androidx.recyclerview.widget.GridLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.ui.components.UiTokens;
-import org.chimeramc.client.util.TouchTargetHelper;
-import org.chimeramc.client.ui.animation.DynamicAnim;
-import org.chimeramc.client.core.mods.inbuilt.ExternalModuleProvider;
-import org.chimeramc.client.core.mods.inbuilt.InbuiltModuleProvider;
-import org.chimeramc.client.core.mods.inbuilt.UnifiedMod;
-import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
-import org.chimeramc.client.core.replay.ReplayControls;
-import org.chimeramc.client.core.replay.ReplayPanel;
-import org.chimeramc.client.core.mods.inbuilt.model.ModIds;
-import org.chimeramc.client.core.mods.pvp.PvpSectionPolicy;
-import org.chimeramc.client.core.mods.inbuilt.model.ModLoadoutStore;
-import org.chimeramc.client.core.mods.inbuilt.vip.ModCategory;
-import org.chimeramc.client.launcher.controller.ControllerConnectionMonitor;
-import org.chimeramc.client.util.MainThread;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-
-public class ModMenuOverlay {
-
- private final Activity activity;
- private View overlayView;
- private WindowManager windowManager;
- private WindowManager.LayoutParams wmParams;
- private final Handler handler = MainThread.handler();
- private boolean isShowing = false;
-
- private RecyclerView modsRecycler;
- private final android.os.Handler searchHandler = MainThread.handler();
- private final Runnable searchRunnable = this::applyFilters;
- private ModMenuAdapter adapter;
- private ModMenuTheme theme;
- /** The menu's own appearance settings (M6), read on open and re-applied from the Settings tab. */
- private ModMenuSettings menuSettings;
- /** The destination the rail is currently showing, so a theme rebuild can restore it. */
- private ModMenuPage activePage;
- /** The page to open on, set when showPage() has to open the menu first. */
- private ModMenuPage pendingPage;
- /** A section filter asked for before the views existed; consumed by {@link #setupViews}. */
- private ModuleFilter pendingFilter;
- private boolean hasStaggeredOnce = false;
- private EditText searchInput;
- private ImageButton clearSearchBtn;
- private TextView navGeneral, navSettings, navHudEditor, navCosmetics, navPacks, navVoice, navReplay;
-
- /** The icon rail. Built from ModMenuPage.pages(), the one page registry the whole menu reads. */
- private ModMenuRail rail;
- private TextView filterAll, filterFavorites, filterEnabled, filterInbuilt, filterExternal,
- filterPvp, filterVisuals, filterMisc, filterHud;
- private TextView moduleCountText, emptyStateText;
- private TextView compactFilterSelector, compactModuleCount;
-
- // The live stats strip in the top bar. Refreshed on a one-second tick while the menu is open,
- // and never when it is closed, so it costs nothing during gameplay.
- private TextView statsFps, statsPing, statsBattery;
- private final Runnable statsTick = new Runnable() {
- @Override
- public void run() {
- refreshStatsStrip();
- if (isShowing) handler.postDelayed(this, 1000L);
- }
- };
-
- /**
- * Refreshes the blurred panel backdrop at the configured blur rate (M4/M6).
- *
- * Self-rescheduling and only while the menu is open, so the capture costs nothing during
- * ordinary play. The interval comes from the blur-fps setting through the same pure rule the
- * wheel uses; a setting of 0 means "no refresh", which is why this re-posts with a guard rather
- * than a fixed delay.
- */
- private final Runnable backdropTick = new Runnable() {
- @Override
- public void run() {
- if (!isShowing || overlayView == null) return;
- // The OS accessibility switches can force the glass off, in which case there is nothing
- // to refresh.
- if (effectivePanelGlass() == ModMenuSettings.GlassIntensity.OFF) return;
- long interval = menuSettings == null
- ? RadialWheelMaterial.blurIntervalMs(RadialWheelMaterial.BLUR_FPS_DEFAULT)
- : RadialWheelMaterial.blurIntervalMs(menuSettings.blurFps());
- if (interval <= 0L) return;
- // Drop the cached capture so the next apply requests a fresh one.
- if (backdropCapture != null) {
- backdropCapture.captureIfDue(activity.getWindow(),
- android.os.SystemClock.uptimeMillis(),
- menuSettings == null ? RadialWheelMaterial.BLUR_FPS_DEFAULT
- : menuSettings.blurFps(),
- new WheelBackdropCapture.Callback() {
- @Override
- public void onCaptured(android.graphics.Bitmap capture) {
- if (overlayView == null || capture == null) return;
- backdropBitmap = capture;
- android.widget.ImageView view =
- overlayView.findViewById(R.id.mod_menu_backdrop);
- if (view != null) showBackdropBitmap(view, capture);
- }
-
- @Override
- public void onFailed() {
- }
- });
- }
- handler.postDelayed(this, interval);
- }
- };
- private View settingsContainer;
- private View modulesContainer;
- private FrameLayout cosmeticsContainer;
- private FrameLayout packsContainer;
- private FrameLayout voiceContainer;
- private VoicePanel voicePanel;
- private FrameLayout replayContainer;
- private ReplayPanel replayPanel;
- private FrameLayout controllerContainer;
- private FrameLayout keyboardContainer;
- private ModMenuControllerSection controllerSection;
- private ModMenuKeyboardSection keyboardSection;
- private PackChangerPanel packChangerPanel;
- private View emptyState;
- private View menuContainer;
- private View modMenuTopBar;
- private View modMenuLogo;
- private View filterBar;
- private View compactFilterBar;
- private CosmeticsPanel cosmeticsPanel;
-
- // --- Panel glass (M4/M6) ------------------------------------------------------------------
-
- /** The glass renderer, shared with the wheel so the panel and the wheel are one material. */
- private final WheelGlassRenderer glassRenderer = new WheelGlassRenderer();
- /** The backdrop capture for the panel, created lazily on the first glass-on render. */
- private WheelBackdropCapture backdropCapture;
- /** The raw capture currently shown (or pending) on the backdrop layer. */
- private android.graphics.Bitmap backdropBitmap;
- /** The blurred copy for the pre-API-31 route, cached so a refresh does not re-blur. */
- private android.graphics.Bitmap backdropBlurred;
- private Switch notificationsSwitch;
- private Switch pauseMenuOnlySwitch;
- private Switch compactModeSwitch;
- private SeekBar modMenuOpacitySeekBar;
- private TextView modMenuOpacityText;
- private SeekBar modMenuPrimaryOpacitySeekBar;
- private TextView modMenuPrimaryOpacityText;
- private SeekBar modMenuButtonOpacitySeekBar;
- private TextView modMenuButtonOpacityText;
- /**
- * The HUD editor: drag handle, safe-area guide, button size/opacity controls and the mode
- * switch. Extracted so this class keeps headroom under the file-size budget.
- */
- private HudEditorController hudEditor;
- private boolean compactMode = false;
- private GridLayoutManager modsLayoutManager;
-
- /** Live keybind labels on the Settings tab, refreshed when a bind is set or cleared. */
- private TextView keyboardBindText;
- private TextView controllerBindText;
- /** The connected pad's 2D illustration shown beside the controller bind row. */
- private org.chimeramc.client.launcher.ui.views.Controller3DView controllerBindIllustration;
- /** Chips for the saved module loadouts on the Settings tab. */
- private android.widget.LinearLayout loadoutChipRow;
-
- /** Delivers a hardware key to an open bind picker. Returns true when one consumed it. */
- public interface BindCapture {
- void onKey(int keyCode);
- }
-
- /**
- * Offers the raw key to whichever menu is currently waiting for a bind.
- *
- *
The capture itself lives in {@link org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay},
- * shared by every bind picker in the menu, so two pickers cannot both hold a capture and
- * silently steal each other's presses. This method is kept as the single
- * entry point the game activity calls at the top of {@code dispatchKeyEvent}.
- */
- public static boolean deliverBindKey(int keyCode) {
- return org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay.deliver(keyCode);
- }
-
- static boolean isCapturingBind() {
- return org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay.isCapturing();
- }
-
-
- private List allMods = new ArrayList<>();
- private List filteredMods = new ArrayList<>();
- private final Set favoriteKeys = new HashSet<>();
-
- /**
- * The Compose presentation, or null until it is attached (and after a failure to attach).
- *
- * Kept as a collaborator rather than inline so this class stays inside its size budget, and
- * so the Compose layer can be dropped entirely on a device where it fails to initialise: the
- * View menu underneath keeps working either way.
- */
- private ModMenuScreen composeScreen;
- private ModuleFilter activeFilter = ModuleFilter.ALL;
- /**
- * How many one-shot action rows the current section shows (M3).
- *
- *
The empty state needs it: the actions live in the adapter, and an empty module list with an
- * action row on screen is not an empty screen.
- */
- private int visibleActionCount = 0;
-
- private ModMenuCallback callback;
- private ModNotificationManager notificationManager;
-
- private void crossfade(View view) {
- view.setAlpha(0f);
- view.setTranslationX(30f);
- view.animate()
- .alpha(1f)
- .translationX(0f)
- .setDuration(250)
- .setInterpolator(new android.view.animation.DecelerateInterpolator(1.5f))
- .start();
- }
-
- private void animateMenuEnter(final View menuContainer) {
- menuContainer.setAlpha(0f);
- menuContainer.setScaleX(0.85f);
- menuContainer.setScaleY(0.85f);
-
- menuContainer.post(() -> {
- menuContainer.setPivotX(menuContainer.getWidth() / 2f);
- menuContainer.setPivotY(menuContainer.getHeight() / 2f);
-
- int opacity = InbuiltModManager.getInstance(activity).getModMenuOpacity();
- float targetAlpha = opacity / 100f;
-
- menuContainer.animate()
- .alpha(targetAlpha)
- .scaleX(1f)
- .scaleY(1f)
- .setDuration(220)
- .setInterpolator(new android.view.animation.DecelerateInterpolator(1.5f))
- .withLayer()
- .start();
- });
- }
-
- private void animateMenuExit(final View menuContainer, Runnable onEnd) {
- menuContainer.setPivotX(menuContainer.getWidth() / 2f);
- menuContainer.setPivotY(menuContainer.getHeight() / 2f);
-
- menuContainer.animate()
- .alpha(0f)
- .scaleX(0.85f)
- .scaleY(0.85f)
- .setDuration(180)
- .setInterpolator(new android.view.animation.AccelerateInterpolator(1.5f))
- .withLayer()
- .setListener(new android.animation.AnimatorListenerAdapter() {
- @Override
- public void onAnimationEnd(android.animation.Animator animation) {
- menuContainer.animate().setListener(null);
- onEnd.run();
- }
- })
- .start();
- }
-
- private int getAccentColor() {
- return theme != null ? theme.accent() : ModMenuTheme.DEFAULT_ACCENT;
- }
-
- public interface ModMenuCallback {
- void onModToggled(String modId, boolean enabled);
- void onButtonOpacityChanged(int opacity);
- }
-
- public ModMenuOverlay(Activity activity) {
- this.activity = activity;
- this.theme = new ModMenuTheme(activity);
- this.windowManager = (WindowManager) activity.getSystemService(Activity.WINDOW_SERVICE);
- this.notificationManager = new ModNotificationManager(activity);
- }
-
- public void setCallback(ModMenuCallback callback) {
- this.callback = callback;
- }
-
- public void show() {
- if (isShowing) {
- refreshMods();
- refreshVoiceSectionIfVisible();
- return;
- }
- showInternal();
- }
-
- /**
- * Opens the menu on a named page, for callers that are not a rail tap (the radial wheel's
- * sectors). Safe when the menu is already open: it just switches page.
- */
- public void showPage(ModMenuPage page) {
- ModMenuPage target = page == null ? ModMenuPage.GENERAL : page;
- if (!isShowing) {
- pendingPage = target;
- show();
- return;
- }
- selectPage(target);
- }
-
- /**
- * Opens the module list already filtered to a section, for the radial wheel's MODS and HUD
- * sectors.
- *
- *
The wheel and the menu are two views of one screen, so a sector must land on the same
- * filtered list the filter chip would produce rather than a second, hand-built list. The filter
- * is applied after the views exist (a pending filter is honoured by {@link #setupViews}), so a
- * cold open and an already-open menu behave identically.
- */
- public void showModulesFiltered(ModuleFilter filter) {
- ModuleFilter target = filter == null ? ModuleFilter.ALL : filter;
- if (!isShowing) {
- pendingPage = ModMenuPage.GENERAL;
- pendingFilter = target;
- show();
- return;
- }
- selectPage(ModMenuPage.GENERAL);
- setModuleFilter(target);
- }
-
- private void showInternal() {
- if (isShowing || activity.isFinishing() || activity.isDestroyed()) return;
-
- try {
- overlayView = LayoutInflater.from(activity).inflate(R.layout.overlay_mod_menu, null);
-
- // The overlay root is clickable so a background tap closes the menu. A clickable
- // View is focusable, and a controller's d-pad or stick moves the focus onto it; the
- // platform then paints its default focus highlight — a translucent white wash over
- // the whole screen, which reads as "the menu put a 50% white overlay up" whenever the
- // player so much as nudges the stick. The highlight is suppressed here rather than
- // only in XML so it cannot come back through a theme or a re-inflated layout.
- // Applied *after* setupViews/loadMods below: the module list is populated there and
- // can take focus for its first card, which would restore the very highlight this
- // suppresses.
-
- int uiOptions = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
- | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
- | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
- | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
- | View.SYSTEM_UI_FLAG_FULLSCREEN
- | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
- overlayView.setSystemUiVisibility(uiOptions);
-
- overlayView.setOnSystemUiVisibilityChangeListener(visibility -> {
- if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) {
- if (overlayView != null) {
- overlayView.setSystemUiVisibility(uiOptions);
- }
- }
- });
-
- setupViews();
- loadMods();
- if (composeScreen == null) {
- composeScreen = new ModMenuScreen(
- activity, overlayView, allMods, favoriteKeys,
- this::applyComposeModuleToggle, this::hide);
- }
- composeScreen.attach();
- disableFocusHighlight(overlayView);
-
- wmParams = new WindowManager.LayoutParams(
- WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.TYPE_APPLICATION_PANEL,
- WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
- | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
- | WindowManager.LayoutParams.FLAG_FULLSCREEN,
- PixelFormat.TRANSLUCENT
- );
- if (ApiLevels.atLeastP()) {
- wmParams.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
- }
- wmParams.gravity = Gravity.CENTER;
- wmParams.token = activity.getWindow().getDecorView().getWindowToken();
-
- windowManager.addView(overlayView, wmParams);
- isShowing = true;
- maybeAttachRenderDebugOverlay();
-
- // Start the stats strip tick. Only while the menu is open, so the FPS/battery read
- // costs nothing during ordinary play.
- handler.removeCallbacks(statsTick);
- refreshStatsStrip();
- handler.postDelayed(statsTick, 1000L);
-
- // Start the backdrop refresh tick at the configured blur rate (M6). Also removed in
- // hide(), so the capture never runs while the menu is closed.
- handler.removeCallbacks(backdropTick);
- handler.postDelayed(backdropTick, Math.max(1L,
- RadialWheelMaterial.blurIntervalMs(menuSettings == null
- ? RadialWheelMaterial.BLUR_FPS_DEFAULT : menuSettings.blurFps())));
-
- overlayView.setAlpha(0f);
- overlayView.animate().alpha(1f).setDuration(220).start();
-
- View menuContainer = overlayView.findViewById(R.id.mod_menu_container);
- if (menuContainer != null) {
- animateMenuEnter(menuContainer);
- }
- } catch (Throwable e) {
- // Throwable, not Exception: an UnsatisfiedLinkError from a native symbol this build does
- // not ship is an Error, so it escaped the old catch and took the whole app down the
- // moment the menu opened. The fallback path still shows the menu.
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:show",
- "The mod menu could not open; falling back", e);
- showFallback();
- }
- }
-
-
- /**
- * Persists a module toggle that came from the Compose menu.
- *
- *
The Compose screen does not touch storage: it reports the intent and this method applies it
- * through the same manager calls the View menu uses, so the two presentations cannot diverge
- * about what "enabled" means or where it is stored.
- */
- void applyComposeModuleToggle(String stableKey, boolean enabled) {
- for (UnifiedMod mod : allMods) {
- if (!org.chimeramc.client.ui.compose.modmenu.ModMenuStateBuilder
- .matchesKey(mod, stableKey)) {
- continue;
- }
- boolean applied = org.chimeramc.client.ui.compose.modmenu.ModMenuStateBuilder
- .toggle(mod, enabled);
- InbuiltModManager manager = InbuiltModManager.getInstance(activity);
- if (mod.getSource() == UnifiedMod.Source.INBUILT) {
- manager.setInbuiltModEnabled(mod.getModId(), applied);
- } else {
- manager.setExternalModuleEnabled(mod.getModId(), applied);
- }
- break;
- }
- }
-
- private void showFallback() {
- if (isShowing) return;
- ViewGroup rootView = activity.findViewById(android.R.id.content);
- if (rootView == null) return;
-
- overlayView = LayoutInflater.from(activity).inflate(R.layout.overlay_mod_menu, null);
- setupViews();
- loadMods();
- disableFocusHighlight(overlayView);
-
- FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
- FrameLayout.LayoutParams.MATCH_PARENT,
- FrameLayout.LayoutParams.MATCH_PARENT
- );
- rootView.addView(overlayView, params);
- isShowing = true;
- wmParams = null;
- maybeAttachRenderDebugOverlay();
-
- overlayView.setAlpha(0f);
- overlayView.animate().alpha(1f).setDuration(220).start();
-
- View menuContainer = overlayView.findViewById(R.id.mod_menu_container);
- if (menuContainer != null) {
- animateMenuEnter(menuContainer);
- }
- }
-
- /**
- * Stops the platform painting a focus highlight over the whole overlay.
- *
- *
A clickable View is implicitly focusable. The d-pad and the analogue stick both move
- * focus, and when the overlay root takes it the framework draws its default focus highlight:
- * a full-screen translucent white wash. To the player that is a 50% white overlay appearing
- * the moment the controller is touched, which obscures the game and the menu itself.
- *
- *
{@code focusable=false} on the root is the primary fix — it removes the view from focus
- * search entirely, so no highlight can be painted for any input. It is applied to the root
- * passed in (this helper is called with the overlay root); children keep their focusability
- * for controller navigation and only get the highlight flag cleared. Only the highlight flag
- * is ever touched — never the background, which carries each control's real styling. minSdk
- * is 28, so {@code setDefaultFocusHighlightEnabled} is always available.
- */
- private static void disableFocusHighlight(View view) {
- if (view == null) return;
- view.setFocusable(false);
- view.setFocusableInTouchMode(false);
- view.setDefaultFocusHighlightEnabled(false);
- // Clearing an already-held focus matters as much as clearing the flag. The highlight can
- // be painted for the view that holds focus right now, and a stick nudge can hand focus to
- // the root — or, after a rebind, to a card — while the menu is already open. Dropping the
- // flag does not retroactively remove the highlight from the currently-focused view, so
- // focus is released here. The walk also clears the flag on every descendant (buttons stay
- // focusable for controller navigation, but must not paint the highlight), and releases
- // focus held by any descendant rather than the root alone.
- clearFocusHighlightRecursive(view);
- }
-
- /**
- * Refreshes the FPS / ping / battery strip in the top bar.
- *
- *
FPS comes from the same inbuilt {@code FpsMod} the FPS overlay uses, so the two can never
- * disagree. Battery comes from the sticky {@code ACTION_BATTERY_CHANGED} broadcast, and ping
- * has no source in this build — it renders the em dash rather than a fabricated figure, which
- * is why {@link ModStatsFormatter} owns the "no reading" rule.
- */
- private void refreshStatsStrip() {
- if (statsFps != null) {
- int fps = 0;
- try {
- if (org.chimeramc.client.launcher.core.mods.inbuilt.nativemod.FpsMod.nativeIsInitialized()) {
- fps = org.chimeramc.client.launcher.core.mods.inbuilt.nativemod.FpsMod.nativeGetFps();
- }
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:536", "Ignored a failure at ModMenuOverlay:536", ignored);
- }
- statsFps.setText(ModStatsFormatter.fps(fps));
- }
- if (statsPing != null) {
- statsPing.setText(ModStatsFormatter.ping(0));
- }
- if (statsBattery != null) {
- statsBattery.setText(readBattery());
- }
- }
-
- private String readBattery() {
- try {
- android.content.Intent battery = activity.registerReceiver(null,
- new android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED));
- if (battery == null) return ModStatsFormatter.battery(-1, false);
- int level = battery.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1);
- int scale = battery.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, -1);
- if (level < 0 || scale <= 0) return ModStatsFormatter.battery(-1, false);
- int percent = Math.round(level * 100f / scale);
- int status = battery.getIntExtra(android.os.BatteryManager.EXTRA_STATUS, -1);
- boolean charging = status == android.os.BatteryManager.BATTERY_STATUS_CHARGING
- || status == android.os.BatteryManager.BATTERY_STATUS_FULL;
- return ModStatsFormatter.battery(percent, charging);
- } catch (Throwable t) {
- return ModStatsFormatter.battery(-1, false);
- }
- }
-
- private static void clearFocusHighlightRecursive(View view) {
- if (view == null) return;
- view.setDefaultFocusHighlightEnabled(false);
- if (view.isFocused()) view.clearFocus();
- if (view instanceof ViewGroup) {
- ViewGroup group = (ViewGroup) view;
- for (int i = 0; i < group.getChildCount(); i++) {
- clearFocusHighlightRecursive(group.getChildAt(i));
- }
- }
- }
-
-
- /**
- * Attaches the live render debug overlay when the player turned it on, so the native draw
- * values can be watched in third person. Off by default; a no-op then.
- */
- private void maybeAttachRenderDebugOverlay() {
- try {
- InbuiltModManager manager = InbuiltModManager.getInstance(activity);
- if (manager == null || !manager.isRenderDebugOverlayEnabled()) return;
- if (overlayView instanceof FrameLayout) {
- renderDebugOverlay = RenderDebugOverlay.attach(activity, (FrameLayout) overlayView);
- if (renderDebugOverlay != null) renderDebugOverlay.start();
- }
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:592", "Ignored a failure at ModMenuOverlay:592", ignored);
- }
- }
-
- private RenderDebugOverlay renderDebugOverlay;
-
- private void setupViews() {
- // The overlay tree is re-inflated on every show(), so any panel that was attached to the
- // previous tree is now parented to a detached view. Reset the cached references (and
- // release the ones that own executors) here, or a section that caches its panel would
- // build it once and then render blank on every reopen because its view belongs to the
- // old, discarded tree.
- releasePanels();
- menuContainer = overlayView.findViewById(R.id.mod_menu_container);
- modMenuTopBar = overlayView.findViewById(R.id.mod_menu_topbar);
- modMenuLogo = overlayView.findViewById(R.id.mod_menu_logo);
- filterBar = overlayView.findViewById(R.id.filter_bar);
- compactFilterBar = overlayView.findViewById(R.id.compact_filter_bar);
- compactFilterSelector = overlayView.findViewById(R.id.compact_filter_selector);
- compactModuleCount = overlayView.findViewById(R.id.compact_module_count);
- ImageButton closeBtn = overlayView.findViewById(R.id.btn_close_menu);
- searchInput = overlayView.findViewById(R.id.search_input);
- clearSearchBtn = overlayView.findViewById(R.id.btn_clear_search);
- modsRecycler = overlayView.findViewById(R.id.mods_grid_recycler);
- // The destination entries left the top bar for the icon rail, so these ids no longer exist
- // in the layout. The fields are kept (always null) so updateNavigationItems' call sites do
- // not change shape; the rail is what paints selection now.
- navGeneral = null;
- navVoice = null;
- navSettings = null;
- navHudEditor = null;
- navCosmetics = null;
- navPacks = null;
- navReplay = null;
- buildRail();
- determineInputMode();
- filterAll = overlayView.findViewById(R.id.filter_all);
- filterFavorites = overlayView.findViewById(R.id.filter_favorites);
- filterEnabled = overlayView.findViewById(R.id.filter_enabled);
- filterInbuilt = overlayView.findViewById(R.id.filter_inbuilt);
- filterExternal = overlayView.findViewById(R.id.filter_external);
- filterPvp = overlayView.findViewById(R.id.filter_pvp);
- filterVisuals = overlayView.findViewById(R.id.filter_visuals);
- filterMisc = overlayView.findViewById(R.id.filter_misc);
- filterHud = overlayView.findViewById(R.id.filter_hud);
- moduleCountText = overlayView.findViewById(R.id.module_count_text);
- statsFps = overlayView.findViewById(R.id.mod_stats_fps);
- statsPing = overlayView.findViewById(R.id.mod_stats_ping);
- statsBattery = overlayView.findViewById(R.id.mod_stats_battery);
- settingsContainer = overlayView.findViewById(R.id.settings_container);
- modulesContainer = overlayView.findViewById(R.id.modules_container);
- cosmeticsContainer = overlayView.findViewById(R.id.cosmetics_container);
- packsContainer = overlayView.findViewById(R.id.packs_container);
- voiceContainer = overlayView.findViewById(R.id.voice_container);
- replayContainer = overlayView.findViewById(R.id.replay_container);
- controllerContainer = overlayView.findViewById(R.id.controller_container);
- keyboardContainer = overlayView.findViewById(R.id.keyboard_container);
- emptyState = overlayView.findViewById(R.id.empty_state);
- emptyStateText = overlayView.findViewById(R.id.empty_state_text);
- notificationsSwitch = overlayView.findViewById(R.id.switch_notifications);
- pauseMenuOnlySwitch = overlayView.findViewById(R.id.switch_pause_menu_only);
- compactModeSwitch = overlayView.findViewById(R.id.switch_compact_mod_menu);
-
- View modMenuContainer = overlayView.findViewById(R.id.mod_menu_container);
-
- // The HUD editor owns its tool views, drag handle, sliders and the floating-window switch.
- // It is created here, after the overlay is inflated, and re-pointed at the current window
- // params on every show.
- if (hudEditor == null) {
- hudEditor = new HudEditorController(activity, new HudEditorController.Host() {
- @Override
- public void onEditorNavigation() {
- updateNavigationItems(false, false, true, false, false, false, false);
- }
-
- @Override
- public void onEditorClosed() {
- showModulesSection();
- }
-
- @Override
- public void animateMenuEnter(View container) {
- ModMenuOverlay.this.animateMenuEnter(container);
- }
- });
- }
- hudEditor.updateWindow(windowManager, wmParams);
- hudEditor.bind(overlayView, windowManager, wmParams);
-
-
- // Touch feedback on the stable chrome (filter chips + close). Recycler rows get their own
- // feedback in the adapter, since they are recycled and rebound; the rail tiles get theirs
- // from ModMenuRail's own click handling.
- for (View v : new View[]{
- filterAll, filterFavorites, filterEnabled, filterInbuilt, filterExternal, filterPvp,
- filterVisuals, filterMisc, closeBtn, clearSearchBtn}) {
- if (v != null) DynamicAnim.applyPressScale(v);
- }
-
- // The chrome icons draw at 22-36dp. They stay that size and gain a 48dp touch area, which
- // is the difference between a control a player hits first time and one they miss while a
- // game is running behind the menu.
- for (int id : new int[]{R.id.btn_close_menu, R.id.btn_clear_search,
- R.id.btn_back_to_modules, R.id.btn_hud_save, R.id.btn_hud_reset,
- R.id.btn_hud_cancel}) {
- TouchTargetHelper.expand(overlayView.findViewById(id));
- }
-
- // Save / reset / cancel are wired by HudEditorController.bind().
-
- // Close on background tap
- overlayView.setOnClickListener(v -> {
- // Only hide if not in HUD editor mode
- if (hudEditor == null || !hudEditor.isOpen()) {
- hide();
- }
- });
- menuContainer.setOnClickListener(v -> {}); // Consume clicks
- View hudTools = hudEditor != null ? hudEditor.toolsView() : null;
- if (hudTools != null) {
- hudTools.setOnClickListener(v -> {}); // Consume clicks
- }
-
- closeBtn.setOnClickListener(v -> hide());
-
- // Search functionality
- searchInput.addTextChangedListener(new TextWatcher() {
- @Override
- public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
- @Override
- public void onTextChanged(CharSequence s, int start, int before, int count) {
- searchHandler.removeCallbacks(searchRunnable);
- searchHandler.postDelayed(searchRunnable, 60L);
- clearSearchBtn.setVisibility(s.length() > 0 ? View.VISIBLE : View.GONE);
- }
- @Override
- public void afterTextChanged(Editable s) {}
- });
-
- clearSearchBtn.setOnClickListener(v -> {
- searchInput.setText("");
- clearSearchBtn.setVisibility(View.GONE);
- });
- setupFilterButtons();
- setupCompactFilter();
-
- View btnBackToModules = overlayView.findViewById(R.id.btn_back_to_modules);
- if (btnBackToModules != null) {
- btnBackToModules.setOnClickListener(v -> showModulesSection());
- }
-
- // Navigation. The top bar carries no destinations any more; these text entries are gone
- // from the layout and the rail drives routing via selectPage(). The bindings stay
- // null-guarded so a layout that still declares one keeps working.
- if (navVoice != null) navVoice.setOnClickListener(v -> showVoiceSection());
- if (navGeneral != null) navGeneral.setOnClickListener(v -> showModulesSection());
- if (navSettings != null) navSettings.setOnClickListener(v -> showSettingsSection());
- if (navCosmetics != null) navCosmetics.setOnClickListener(v -> showCosmeticsSection());
- if (navPacks != null) navPacks.setOnClickListener(v -> showPacksSection());
- if (navReplay != null) navReplay.setOnClickListener(v -> showReplaySection());
-
- // Settings
- InbuiltModManager modManager = InbuiltModManager.getInstance(activity);
- compactMode = modManager.isModMenuCompact();
- notificationsSwitch.setChecked(modManager.isNotificationsEnabled());
- notificationsSwitch.setOnCheckedChangeListener((btn, checked) -> {
- modManager.setNotificationsEnabled(checked);
- });
-
- if (pauseMenuOnlySwitch != null) {
- pauseMenuOnlySwitch.setChecked(modManager.isPauseMenuOnly());
- pauseMenuOnlySwitch.setOnCheckedChangeListener((btn, checked) -> {
- modManager.setPauseMenuOnly(checked);
- });
- }
-
- if (compactModeSwitch != null) {
- compactModeSwitch.setChecked(compactMode);
- compactModeSwitch.setOnCheckedChangeListener((btn, checked) -> {
- modManager.setModMenuCompact(checked);
- setCompactMode(checked);
- });
- }
-
- modMenuOpacitySeekBar = overlayView.findViewById(R.id.seekbar_mod_menu_opacity);
- modMenuOpacityText = overlayView.findViewById(R.id.text_mod_menu_opacity);
- int currentMenuOpacity = modManager.getModMenuOpacity();
- modMenuOpacitySeekBar.setProgress(currentMenuOpacity);
- modMenuOpacityText.setText(activity.getString(R.string.mod_menu_percent_value, currentMenuOpacity));
- modMenuOpacitySeekBar.setOnSeekBarChangeListener(new android.widget.SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(android.widget.SeekBar seekBar, int progress, boolean fromUser) {
- if (fromUser) {
- modMenuOpacityText.setText(activity.getString(R.string.mod_menu_percent_value, progress));
- modManager.setModMenuOpacity(progress);
- applyMenuOpacity();
- }
- }
- @Override
- public void onStartTrackingTouch(android.widget.SeekBar seekBar) {}
- @Override
- public void onStopTrackingTouch(android.widget.SeekBar seekBar) {}
- });
-
- modMenuPrimaryOpacitySeekBar = overlayView.findViewById(R.id.seekbar_mod_menu_primary_opacity);
- modMenuPrimaryOpacityText = overlayView.findViewById(R.id.text_mod_menu_primary_opacity);
- if (modMenuPrimaryOpacitySeekBar != null) {
- int currentPrimaryOpacity = modManager.getModMenuPrimaryOpacity();
- modMenuPrimaryOpacitySeekBar.setProgress(currentPrimaryOpacity);
- if (modMenuPrimaryOpacityText != null) {
- modMenuPrimaryOpacityText.setText(
- activity.getString(R.string.mod_menu_percent_value, currentPrimaryOpacity));
- }
- modMenuPrimaryOpacitySeekBar.setOnSeekBarChangeListener(new android.widget.SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(android.widget.SeekBar seekBar, int progress, boolean fromUser) {
- if (!fromUser) return;
- if (modMenuPrimaryOpacityText != null) {
- modMenuPrimaryOpacityText.setText(
- activity.getString(R.string.mod_menu_percent_value, progress));
- }
- modManager.setModMenuPrimaryOpacity(progress);
- applyMenuOpacity();
- }
- @Override
- public void onStartTrackingTouch(android.widget.SeekBar seekBar) {}
- @Override
- public void onStopTrackingTouch(android.widget.SeekBar seekBar) {}
- });
- }
-
- modMenuButtonOpacitySeekBar = overlayView.findViewById(R.id.seekbar_mod_menu_button_opacity);
- modMenuButtonOpacityText = overlayView.findViewById(R.id.text_mod_menu_button_opacity);
- int currentButtonOpacity = modManager.getModMenuButtonOpacity();
- modMenuButtonOpacitySeekBar.setProgress(currentButtonOpacity);
- modMenuButtonOpacityText.setText(activity.getString(R.string.mod_menu_percent_value, currentButtonOpacity));
- modMenuButtonOpacitySeekBar.setOnSeekBarChangeListener(new android.widget.SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(android.widget.SeekBar seekBar, int progress, boolean fromUser) {
- if (fromUser) {
- modMenuButtonOpacityText.setText(activity.getString(R.string.mod_menu_percent_value, progress));
- modManager.setModMenuButtonOpacity(progress);
- if (callback != null) {
- callback.onButtonOpacityChanged(progress);
- }
- }
- }
- @Override
- public void onStartTrackingTouch(android.widget.SeekBar seekBar) {}
- @Override
- public void onStopTrackingTouch(android.widget.SeekBar seekBar) {}
- });
-
- // M1/M6: the menu's own appearance settings drive the theme, the accent and the motion.
- // Read before the surfaces are applied — a theme built while the settings are still null
- // resolves to the defaults, so the panel would open with the wrong preset and no glass.
- menuSettings = InbuiltModManager.getInstance(activity).getModMenuSettings();
-
- applyMenuOpacity();
-
- setupKeybindSettings(modManager);
- setupLoadoutSettings(modManager);
- setupModMenuAppearanceSettings();
-
- adapter = new ModMenuAdapter(new ModMenuTheme(activity, menuSettings));
- adapter.setSettings(menuSettings);
- adapter.setCompactMode(compactMode);
- // Two columns on a wide window, one below the 560dp threshold (the spec's rule). The old
- // four-column grid was too dense; the two-column module list is the blueberry layout.
- int spanCount = compactMode ? 1 : 2;
- modsLayoutManager = new GridLayoutManager(activity, spanCount);
- modsLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
- @Override
- public int getSpanSize(int position) {
- return adapter != null && adapter.isGroupHeader(position)
- ? modsLayoutManager.getSpanCount()
- : 1;
- }
- });
- modsRecycler.setLayoutManager(modsLayoutManager);
- modsRecycler.setItemAnimator(null);
- modsRecycler.setHasFixedSize(true);
- adapter.setOnModActionListener(new ModMenuAdapter.OnModActionListener() {
- @Override
- public void onToggle(UnifiedMod mod, boolean enabled) {
- mod.applyEnabled(enabled);
- InbuiltModManager modManager = InbuiltModManager.getInstance(activity);
- if (enabled && modManager.isNotificationsEnabled()) {
- notificationManager.show(mod.getName(), mod.getStableKey());
- }
- if (callback != null) {
- callback.onModToggled(mod.getStableKey(), enabled);
- }
- if (activeFilter == ModuleFilter.ENABLED) {
- applyFilters();
- }
- }
- @Override
- public void onConfig(UnifiedMod mod) {
- showConfigSection(mod);
- }
- @Override
- public void onFavoriteChanged(UnifiedMod mod, boolean favorite) {
- InbuiltModManager.getInstance(activity).setModFavorite(mod.getStableKey(), favorite);
- if (favorite) {
- favoriteKeys.add(mod.getStableKey());
- } else {
- favoriteKeys.remove(mod.getStableKey());
- }
- if (activeFilter == ModuleFilter.FAVORITES) {
- applyFilters();
- }
- }
- @Override
- public void onAction(String actionId) {
- runMenuAction(actionId);
- }
- @Override
- public void onRowReason(String stableKey) {
- showRowReasonSheet(stableKey);
- }
- @Override
- public void onReorder(String sectionId, java.util.List orderedKeys) {
- InbuiltModManager.getInstance(activity).setModMenuSectionOrder(sectionId, orderedKeys);
- }
- });
- setupReorderTouch();
- modsRecycler.setAdapter(adapter);
- applyCompactModeLayout(compactMode);
-
- // A caller that asked for a specific page (the radial wheel) opened the menu for us, so
- // honour it here — after the views exist. Otherwise the module list is the default.
- ModMenuPage initial = pendingPage;
- pendingPage = null;
- selectPage(initial == null ? ModMenuPage.GENERAL : initial);
- // A caller that also asked for a section filter (the wheel's MODS/HUD sectors) has it
- // applied last, so the list is filtered on the first frame rather than after a flash of
- // the unfiltered list.
- ModuleFilter initialFilter = pendingFilter;
- pendingFilter = null;
- if (initialFilter != null) {
- setModuleFilter(initialFilter);
- }
- }
-
- private void showModulesSection() {
- updateNavigationItems(true, false, false, false, false, false, false);
-
- if (modulesContainer.getVisibility() != View.VISIBLE) {
- modulesContainer.setVisibility(View.VISIBLE);
- crossfade(modulesContainer);
- }
- settingsContainer.setVisibility(View.GONE);
- hideCosmetics();
- hidePacks();
- hideVoice();
- hideReplay();
- hideSupporterPages();
-
- if (overlayView != null) {
- View modConfigContainer = overlayView.findViewById(R.id.mod_config_container);
- View searchContainer = overlayView.findViewById(R.id.search_container);
- View configHeader = overlayView.findViewById(R.id.config_header);
- if (modConfigContainer != null) modConfigContainer.setVisibility(View.GONE);
- if (searchContainer != null) searchContainer.setVisibility(View.VISIBLE);
- if (configHeader != null) configHeader.setVisibility(View.GONE);
- updateFilterBarVisibility();
- }
- }
-
- /**
- * Runs a one-shot action row (M3).
- *
- * Actions are dispatched by id rather than by a captured Runnable, so the list can be
- * rebuilt without the handler pointing at a stale view.
- */
- private void runMenuAction(String actionId) {
- if (actionId == null) return;
- switch (actionId) {
- case ModMenuActionCatalog.ACTION_RESET_HUD:
- InbuiltOverlayManager.getInstance().resetAllPositionsToCenter();
- android.widget.Toast.makeText(activity, R.string.mod_menu_action_reset_hud_done,
- android.widget.Toast.LENGTH_SHORT).show();
- break;
- case ModMenuActionCatalog.ACTION_RELOAD_MODULES:
- reloadModules();
- break;
- case ModMenuActionCatalog.ACTION_MANAGE_WHEEL:
- WheelProfileManagerDialog.show(activity, new ModMenuTheme(activity,
- org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager
- .getInstance(activity).getModMenuSettings()));
- break;
- case ModMenuActionCatalog.ACTION_TOGGLE_HUD: {
- InbuiltOverlayManager manager = InbuiltOverlayManager.getInstance();
- if (manager != null) {
- boolean hidden = !manager.isOverlaysHiddenForScreenshot();
- manager.setOverlaysHiddenForScreenshot(hidden);
- android.widget.Toast.makeText(activity,
- hidden ? R.string.mod_menu_hud_hidden : R.string.mod_menu_hud_shown,
- android.widget.Toast.LENGTH_SHORT).show();
- }
- break;
- }
- case ModMenuActionCatalog.ACTION_MANAGE_LAYOUTS:
- OverlayLayoutManagerDialog.show(activity, new ModMenuTheme(activity,
- org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager
- .getInstance(activity).getModMenuSettings()));
- break;
- default:
- break;
- }
- }
-
- /**
- * Re-scans and re-registers every module, then refreshes the list (M3).
- *
- *
The providers read the registry live on every {@code load}, so re-reading them *is* the
- * reload: a module added or removed while the menu was open appears without restarting the
- * session. The list's selection is dropped by {@link #applyFilters()} so a renumbered row
- * cannot be toggled by a stale controller index.
- */
- private void reloadModules() {
- loadMods();
- }
-
- /**
- * Explains why a row cannot run (M10).
- *
- *
The dialog names the state and offers the one action that makes sense: a crash-disabled
- * module can be re-enabled (that is the whole point of the message), while a module with no
- * hook on this build has nothing to offer but the explanation.
- */
- private void showRowReasonSheet(String stableKey) {
- if (stableKey == null || activity == null) return;
- UnifiedMod mod = findModByKey(stableKey);
- if (mod == null) return;
- ModMenuRowState state = ModMenuRowState.of(true,
- org.chimeramc.client.core.mods.inbuilt.model.ModAvailability
- .isInteractive(mod.getId()), false, mod.isEnabled());
- String reason = rowReasonText(mod, state);
- if (reason == null) return;
-
- boolean reEnableable = state == ModMenuRowState.CRASHED;
- android.app.AlertDialog.Builder builder = new android.app.AlertDialog.Builder(activity)
- .setTitle(mod.getName())
- .setMessage(reason)
- .setNegativeButton(R.string.mod_menu_row_reason_dismiss, null);
- if (reEnableable) {
- builder.setPositiveButton(R.string.mod_menu_row_reason_re_enable, (d, w) -> {
- mod.applyEnabled(true);
- loadMods();
- });
- }
- builder.show();
- }
-
- /** The explanation text for a row's state, or null when the row is healthy. */
- private String rowReasonText(UnifiedMod mod, ModMenuRowState state) {
- switch (state) {
- case CRASHED:
- return activity.getString(R.string.mod_menu_row_crashed);
- case FAILED:
- return activity.getString(R.string.mod_menu_row_failed);
- case UNSUPPORTED:
- if (org.chimeramc.client.core.mods.inbuilt.model.ModAvailability
- .isUnavailable(mod.getId())) {
- String unavailable = org.chimeramc.client.core.mods.inbuilt.model
- .ModAvailability.unavailableReason(mod.getId());
- int reason = org.chimeramc.client.core.mods.inbuilt.model.ModAvailability
- .REASON_SERVER_DECIDED.equals(unavailable)
- ? R.string.mod_unavailable_reason_server
- : (org.chimeramc.client.core.mods.inbuilt.model.ModAvailability
- .REASON_NO_BACKEND.equals(unavailable)
- ? R.string.mod_unavailable_reason_backend
- : R.string.mod_unavailable_reason);
- return activity.getString(reason);
- }
- return activity.getString(R.string.mod_menu_row_unsupported);
- default:
- return null;
- }
- }
-
- /** Finds a module by its stable key, or null when it is not in the current list. */
- private UnifiedMod findModByKey(String stableKey) {
- for (UnifiedMod mod : allMods) {
- if (stableKey.equals(mod.getStableKey())) return mod;
- }
- return null;
- }
-
- /**
- * Enables drag-to-reorder within the module list (M9).
- *
- *
Long-press starts the drag so a normal tap still toggles, and the move is committed to the
- * manager on drop. Only modules move: a group header has no position of its own.
- */
- private void setupReorderTouch() {
- if (modsRecycler == null) return;
- androidx.recyclerview.widget.ItemTouchHelper helper =
- new androidx.recyclerview.widget.ItemTouchHelper(
- new androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback(
- androidx.recyclerview.widget.ItemTouchHelper.UP
- | androidx.recyclerview.widget.ItemTouchHelper.DOWN
- | androidx.recyclerview.widget.ItemTouchHelper.START
- | androidx.recyclerview.widget.ItemTouchHelper.END,
- 0) {
- @Override
- public boolean isLongPressDragEnabled() {
- return adapter != null;
- }
-
- @Override
- public boolean onMove(RecyclerView recyclerView,
- RecyclerView.ViewHolder viewHolder,
- RecyclerView.ViewHolder target) {
- if (adapter == null) return false;
- int from = viewHolder.getBindingAdapterPosition();
- int to = target.getBindingAdapterPosition();
- if (from == RecyclerView.NO_POSITION
- || to == RecyclerView.NO_POSITION) {
- return false;
- }
- return adapter.moveModule(from, to) != null;
- }
-
- @Override
- public boolean canDropOver(RecyclerView recyclerView,
- RecyclerView.ViewHolder current,
- RecyclerView.ViewHolder target) {
- // A module may only move within its own section: crossing into
- // another section would break the one-header-per-run rule and
- // the saved order is stored per section.
- return adapter != null
- && adapter.canReorderBetween(current, target);
- }
-
- @Override
- public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
- // No swipe action: a swipe that deletes a module would be a
- // destructive gesture on a list of toggles.
- }
-
- @Override
- public void clearView(RecyclerView recyclerView,
- RecyclerView.ViewHolder viewHolder) {
- super.clearView(recyclerView, viewHolder);
- if (adapter != null) {
- adapter.onReorderFinished(activeFilter.name());
- }
- }
- });
- helper.attachToRecyclerView(modsRecycler);
- }
-
- private void showSettingsSection() {
- updateNavigationItems(false, false, false, true, false, false, false);
-
- modulesContainer.setVisibility(View.GONE);
- hideCosmetics();
- hidePacks();
- hideVoice();
- hideReplay();
- hideSupporterPages();
- if (settingsContainer.getVisibility() != View.VISIBLE) {
- settingsContainer.setVisibility(View.VISIBLE);
- crossfade(settingsContainer);
- }
-
- if (overlayView != null) {
- View modConfigContainer = overlayView.findViewById(R.id.mod_config_container);
- View searchContainer = overlayView.findViewById(R.id.search_container);
- View configHeader = overlayView.findViewById(R.id.config_header);
- if (modConfigContainer != null) modConfigContainer.setVisibility(View.GONE);
- if (searchContainer != null) searchContainer.setVisibility(View.VISIBLE);
- if (configHeader != null) configHeader.setVisibility(View.GONE);
- if (filterBar != null) filterBar.setVisibility(View.GONE);
- if (compactFilterBar != null) compactFilterBar.setVisibility(View.GONE);
- }
- }
-
- /**
- * Wires the Settings tab's appearance controls (M6).
- *
- *
Segmented presets for the enumerated choices and one slider per continuous value, and
- * nothing free-form: a colour picker or a custom blur radius is the setting that generates
- * "why does my menu look broken" reports. Every control writes through
- * {@link InbuiltModManager#setModMenuSettings} and then re-applies, so the change is visible
- * immediately rather than on the next open.
- */
- private void setupModMenuAppearanceSettings() {
- if (overlayView == null) return;
- if (menuSettings == null) {
- menuSettings = InbuiltModManager.getInstance(activity).getModMenuSettings();
- }
- final ModMenuSettings current = menuSettings;
-
- bindSegmentedGroup(R.id.group_mod_setting_theme, new String[]{
- activity.getString(R.string.mod_menu_theme_navy),
- activity.getString(R.string.mod_menu_theme_true_black),
- activity.getString(R.string.mod_menu_theme_system)},
- current.theme().ordinal(),
- index -> applyMenuSettings(current.withTheme(
- ModMenuSettings.Theme.values()[index])));
-
- bindSegmentedGroup(R.id.group_mod_setting_glass, new String[]{
- activity.getString(R.string.mod_menu_glass_off),
- activity.getString(R.string.mod_menu_glass_subtle),
- activity.getString(R.string.mod_menu_glass_full)},
- current.glassIntensity().ordinal(),
- index -> applyMenuSettings(current.withGlassIntensity(
- ModMenuSettings.GlassIntensity.values()[index])));
-
- bindSegmentedGroup(R.id.group_mod_setting_density, new String[]{
- activity.getString(R.string.mod_menu_density_compact),
- activity.getString(R.string.mod_menu_density_comfortable)},
- current.density().ordinal(),
- index -> applyMenuSettings(current.withDensity(
- ModMenuSettings.Density.values()[index])));
-
- bindSegmentedGroup(R.id.group_mod_setting_toggle_style, new String[]{
- activity.getString(R.string.mod_menu_toggle_switch),
- activity.getString(R.string.mod_menu_toggle_pill_label),
- activity.getString(R.string.mod_menu_toggle_checkbox)},
- current.toggleStyle().ordinal(),
- index -> applyMenuSettings(current.withToggleStyle(
- ModMenuSettings.ToggleStyle.values()[index])));
-
- bindSegmentedGroup(R.id.group_mod_setting_motion, new String[]{
- activity.getString(R.string.mod_menu_motion_full),
- activity.getString(R.string.mod_menu_motion_reduced),
- activity.getString(R.string.mod_menu_motion_system)},
- current.motion().ordinal(),
- index -> applyMenuSettings(current.withMotion(
- ModMenuSettings.Motion.values()[index])));
-
- bindAppearanceSlider(R.id.seekbar_mod_setting_radius, R.id.value_mod_setting_radius,
- ModMenuSettings.MIN_RADIUS_DP, ModMenuSettings.MAX_RADIUS_DP,
- current.cornerRadiusDp(), R.string.mod_menu_setting_value_dp,
- value -> applyMenuSettings(current.withCornerRadiusDp(value)));
-
- bindAppearanceSlider(R.id.seekbar_mod_setting_blur_fps, R.id.value_mod_setting_blur_fps,
- ModMenuSettings.MIN_BLUR_FPS, ModMenuSettings.MAX_BLUR_FPS,
- current.blurFps(), R.string.mod_menu_setting_value_fps,
- value -> applyMenuSettings(current.withBlurFps(value)));
-
- bindAppearanceSlider(R.id.seekbar_mod_setting_hud_opacity,
- R.id.value_mod_setting_hud_opacity,
- ModMenuSettings.MIN_HUD_OPACITY, ModMenuSettings.MAX_HUD_OPACITY,
- current.hudOpacity(), R.string.mod_menu_setting_value_percent,
- value -> applyMenuSettings(current.withHudOpacity(value)));
-
- bindWheelDeadZoneSlider();
-
- buildAccentSwatches(current);
- }
-
- /**
- * Binds the radial wheel's centre dead-zone slider.
- *
- *
The value lives in the manager's preferences rather than in {@link ModMenuSettings}: that
- * object is the menu's appearance, and the dead zone is the wheel's input feel, owned by the
- * overlay that reads it. Writing straight through the manager keeps the running wheel and the
- * stored value in step, because the wheel reads the preference on every open.
- */
- private void bindWheelDeadZoneSlider() {
- if (overlayView == null) return;
- final InbuiltModManager manager = InbuiltModManager.getInstance(activity);
- int percent = Math.round(manager.getWheelCenterDeadZone() * 100f);
- bindAppearanceSlider(R.id.seekbar_mod_setting_wheel_dead_zone,
- R.id.value_mod_setting_wheel_dead_zone,
- Math.round(org.chimeramc.client.core.mods.inbuilt.overlay.RadialWheelMotion
- .CENTER_DEAD_ZONE_MIN_DP * 100f),
- Math.round(org.chimeramc.client.core.mods.inbuilt.overlay.RadialWheelMotion
- .CENTER_DEAD_ZONE_MAX_DP * 100f),
- percent, R.string.mod_menu_setting_value_percent,
- value -> manager.setWheelCenterDeadZone(value / 100f));
- }
-
- /**
- * Builds a segmented row from a label array and reports the chosen index.
- *
- *
The chips are plain TextViews rather than a Spinner: a Spinner's popup is a second window
- * that outlives the host view, and the menu already had a stale-popup bug from one.
- */
- private void bindSegmentedGroup(int groupId, String[] labels, int selected,
- java.util.function.IntConsumer onSelected) {
- ViewGroup group = overlayView.findViewById(groupId);
- if (group == null) return;
- group.removeAllViews();
- for (int i = 0; i < labels.length; i++) {
- TextView chip = new TextView(activity);
- chip.setText(labels[i]);
- chip.setTextSize(12);
- chip.setGravity(android.view.Gravity.CENTER);
- chip.setPadding((int) (14 * density()), (int) (8 * density()),
- (int) (14 * density()), (int) (8 * density()));
- boolean active = i == selected;
- chip.setTextColor(active ? ModMenuPalette.TEXT_PRIMARY : ModMenuPalette.TEXT_SECONDARY);
- android.graphics.drawable.GradientDrawable bg =
- new android.graphics.drawable.GradientDrawable();
- bg.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
- bg.setCornerRadius(ModMenuMetrics.PANEL_RADIUS_DP / 2f * density());
- bg.setColor(active
- ? ModMenuPalette.ACCENT
- : ModMenuPalette.BG_SURFACE_3);
- chip.setBackground(bg);
- android.widget.LinearLayout.LayoutParams params =
- new android.widget.LinearLayout.LayoutParams(0,
- ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
- params.setMarginEnd((int) (6 * density()));
- chip.setLayoutParams(params);
- final int index = i;
- chip.setOnClickListener(v -> onSelected.accept(index));
- DynamicAnim.applyPressScale(chip);
- group.addView(chip);
- }
- }
-
- /** Binds one appearance slider and its value label. */
- private void bindAppearanceSlider(int seekBarId, int valueId, int min, int max, int current,
- int valueFormatRes,
- java.util.function.IntConsumer onChanged) {
- SeekBar bar = overlayView.findViewById(seekBarId);
- TextView label = overlayView.findViewById(valueId);
- if (bar == null) return;
- bar.setMax(max - min);
- bar.setProgress(Math.max(0, Math.min(max - min, current - min)));
- if (label != null) {
- label.setText(activity.getString(valueFormatRes, current));
- }
- bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- int value = min + progress;
- if (label != null) {
- label.setText(activity.getString(valueFormatRes, value));
- }
- if (fromUser) onChanged.accept(value);
- }
-
- @Override
- public void onStartTrackingTouch(SeekBar seekBar) {
- }
-
- @Override
- public void onStopTrackingTouch(SeekBar seekBar) {
- }
- });
- }
-
- /** Builds the six accent swatches (M6). */
- private void buildAccentSwatches(ModMenuSettings current) {
- ViewGroup group = overlayView.findViewById(R.id.group_mod_setting_accent);
- if (group == null) return;
- group.removeAllViews();
- int[] swatches = ModMenuSettings.ACCENT_SWATCHES;
- for (int i = 0; i < swatches.length; i++) {
- View swatch = new View(activity);
- int size = (int) (32 * density());
- android.widget.LinearLayout.LayoutParams params =
- new android.widget.LinearLayout.LayoutParams(size, size);
- params.setMarginEnd((int) (10 * density()));
- swatch.setLayoutParams(params);
- android.graphics.drawable.GradientDrawable bg =
- new android.graphics.drawable.GradientDrawable();
- bg.setShape(android.graphics.drawable.GradientDrawable.OVAL);
- bg.setColor(swatches[i]);
- if (i == current.accentIndex()) {
- bg.setStroke((int) Math.max(2f, 2f * density()), ModMenuPalette.TEXT_PRIMARY);
- }
- swatch.setBackground(bg);
- swatch.setContentDescription(activity.getString(R.string.mod_menu_setting_accent)
- + " " + (i + 1));
- final int index = i;
- swatch.setOnClickListener(v -> applyMenuSettings(current.withAccentIndex(index)));
- DynamicAnim.applyPressScale(swatch);
- group.addView(swatch);
- }
- }
-
- /**
- * Persists and applies a new appearance settings value (M6).
- *
- *
Re-applying immediately is what makes the settings tab useful: a change is visible on the
- * menu behind the sheet rather than on the next open.
- */
- private void applyMenuSettings(ModMenuSettings settings) {
- if (settings == null) return;
- menuSettings = settings;
- InbuiltModManager.getInstance(activity).setModMenuSettings(settings);
- if (adapter != null) {
- adapter.setSettings(settings);
- adapter.setCompactMode(compactMode);
- }
- theme = new ModMenuTheme(activity, settings);
- if (adapter != null) adapter.setTheme(theme);
- applyCompactModeLayout(compactMode);
- applyMenuOpacity();
- // The controls are rebuilt so the active chip/swatch reflects the new value.
- setupModMenuAppearanceSettings();
- if (modsRecycler != null && modsRecycler.getAdapter() != null
- && modsRecycler.getAdapter().getItemCount() > 0) {
- // An appearance change repaints every card, but the rows are the same rows: a range
- // change keeps the list's scroll position and animates the repaint.
- modsRecycler.getAdapter().notifyItemRangeChanged(
- 0, modsRecycler.getAdapter().getItemCount());
- }
- }
-
- private float density() {
- return activity.getResources().getDisplayMetrics().density;
- }
-
- /**
- * Wires the Settings tab's keybind rows.
- *
- * Keyboard capture listens on the row itself (the row is focusable and grabs the key), while
- * the controller capture opens a dialog carrying a live illustration — a control that opens a
- * "press a button" prompt only works if the prompt can actually receive the button, so the
- * dialog takes focus and forwards every key/motion event to the illustration.
- */
- private void setupKeybindSettings(InbuiltModManager modManager) {
- keyboardBindText = overlayView.findViewById(R.id.text_keyboard_bind);
- controllerBindText = overlayView.findViewById(R.id.text_controller_bind);
- controllerBindIllustration = overlayView.findViewById(R.id.bind_controller_illustration);
- View keyboardRow = overlayView.findViewById(R.id.setting_keyboard_bind);
- View controllerRow = overlayView.findViewById(R.id.setting_controller_bind);
-
- refreshKeyboardBindLabel(modManager);
- refreshControllerBindLabel(modManager);
- refreshControllerBindIllustration(modManager);
-
- if (keyboardRow != null) {
- keyboardRow.setFocusableInTouchMode(true);
- keyboardRow.setOnClickListener(v -> openKeyboardBindCapture(modManager));
- }
- if (controllerRow != null) {
- controllerRow.setOnClickListener(v -> openControllerBindPicker(modManager));
- }
- }
-
- /**
- * Wires the Settings tab's loadout row: a chip per saved set (tap to apply, long-press to
- * delete) and a "save current" action. A loadout only touches the ids it stored, so applying
- * an older preset cannot switch off a module added since.
- */
- private void setupLoadoutSettings(InbuiltModManager modManager) {
- loadoutChipRow = overlayView.findViewById(R.id.loadout_chip_row);
- View saveRow = overlayView.findViewById(R.id.setting_save_loadout);
- if (saveRow != null) {
- saveRow.setOnClickListener(v -> promptSaveLoadout(modManager));
- }
- rebuildLoadoutChips(modManager);
- }
-
- private void rebuildLoadoutChips(InbuiltModManager modManager) {
- if (loadoutChipRow == null) return;
- loadoutChipRow.removeAllViews();
- java.util.List loadouts = modManager.getModLoadouts();
-
- if (loadouts.isEmpty()) {
- TextView hint = new TextView(activity);
- hint.setText(R.string.mod_menu_loadout_none);
- hint.setTextColor(0xFF888888);
- hint.setTextSize(11f);
- loadoutChipRow.addView(hint);
- return;
- }
-
- int margin = (int) (8 * activity.getResources().getDisplayMetrics().density);
- for (ModLoadoutStore.Loadout loadout : loadouts) {
- TextView chip = new TextView(activity);
- chip.setText(loadout.name);
- chip.setTextColor(0xFFF1F4F6);
- chip.setTextSize(12f);
- chip.setPadding(margin * 2, margin, margin * 2, margin);
- chip.setBackgroundResource(R.drawable.bg_search_field);
- android.widget.LinearLayout.LayoutParams lp = new android.widget.LinearLayout.LayoutParams(
- android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
- android.view.ViewGroup.LayoutParams.WRAP_CONTENT);
- lp.rightMargin = margin;
- chip.setLayoutParams(lp);
- chip.setOnClickListener(v -> {
- if (modManager.applyLoadout(loadout.name)) {
- loadMods();
- String name = loadout.name;
- Toast.makeText(activity,
- activity.getString(R.string.mod_menu_loadout_applied, name),
- Toast.LENGTH_SHORT).show();
- }
- });
- chip.setOnLongClickListener(v -> {
- confirmDeleteLoadout(modManager, loadout.name);
- return true;
- });
- loadoutChipRow.addView(chip);
- }
- }
-
- private void promptSaveLoadout(InbuiltModManager modManager) {
- final EditText input = new EditText(activity);
- input.setHint(R.string.mod_menu_loadout_name_hint);
- // Seed with a sensible preset name so saving takes one tap in the common case.
- boolean empty = modManager.getModLoadouts().isEmpty();
- input.setText(empty ? activity.getString(R.string.mod_menu_loadout_preset_pvp) : "");
-
- new android.app.AlertDialog.Builder(activity)
- .setTitle(R.string.mod_menu_loadout_save)
- .setView(input)
- .setPositiveButton(R.string.save, (d, w) -> {
- String name = input.getText().toString().trim();
- if (name.isEmpty()) return;
- modManager.saveLoadout(name, currentModuleIds());
- rebuildLoadoutChips(modManager);
- Toast.makeText(activity,
- activity.getString(R.string.mod_menu_loadout_saved, name),
- Toast.LENGTH_SHORT).show();
- })
- .setNegativeButton(R.string.cancel, null)
- .show();
- }
-
- private void confirmDeleteLoadout(InbuiltModManager modManager, String name) {
- new android.app.AlertDialog.Builder(activity)
- .setMessage(activity.getString(R.string.mod_menu_loadout_delete_confirm, name))
- .setPositiveButton(R.string.delete, (d, w) -> {
- modManager.deleteLoadout(name);
- rebuildLoadoutChips(modManager);
- Toast.makeText(activity,
- activity.getString(R.string.mod_menu_loadout_deleted, name),
- Toast.LENGTH_SHORT).show();
- })
- .setNegativeButton(R.string.cancel, null)
- .show();
- }
-
- private java.util.List currentModuleIds() {
- java.util.List ids = new java.util.ArrayList<>();
- for (UnifiedMod mod : allMods) {
- if (mod.getSource() == UnifiedMod.Source.INBUILT) ids.add(mod.getId());
- }
- return ids;
- }
-
- private void refreshKeyboardBindLabel(InbuiltModManager modManager) {
- if (keyboardBindText == null) return;
- keyboardBindText.setText(bindLabel(modManager.getModMenuKeybind()));
- }
-
- private void refreshControllerBindLabel(InbuiltModManager modManager) {
- if (controllerBindText == null) return;
- int code = modManager.getModMenuControllerBind();
- controllerBindText.setText(code == 0
- ? activity.getString(R.string.mod_menu_bind_none)
- : bindLabel(code));
- refreshControllerBindIllustration(modManager);
- }
-
- /**
- * Shows the connected pad's 2D map beside the bind row, with the bound button lit green.
- *
- * A bare key name ("BUTTON L1") does not tell a player which physical button that is, so the
- * whole controller is drawn and the bound control highlighted. Hidden until a bind exists —
- * an unlit controller would read as a decorate nobody asked for.
- */
- private void refreshControllerBindIllustration(InbuiltModManager modManager) {
- if (controllerBindIllustration == null) return;
- int code = modManager.getModMenuControllerBind();
- if (code == 0) {
- controllerBindIllustration.setVisibility(View.GONE);
- return;
- }
- android.view.InputDevice device = firstGamepad();
- if (device == null) {
- controllerBindIllustration.setVisibility(View.GONE);
- return;
- }
- org.chimeramc.client.launcher.controller.ControllerType type =
- org.chimeramc.client.launcher.controller.ControllerType.from(device);
- if (type == null) type = org.chimeramc.client.launcher.controller.ControllerType.XBOX;
- controllerBindIllustration.setType(type);
- controllerBindIllustration.clearConfirmed();
- controllerBindIllustration.setRegionConfirmed(
- controllerBindIllustration.regionIdForKey(code), true);
- controllerBindIllustration.setVisibility(View.VISIBLE);
- }
-
- private String bindLabel(int keyCode) {
- if (keyCode == 0) return activity.getString(R.string.mod_menu_bind_none);
- String name = android.view.KeyEvent.keyCodeToString(keyCode);
- return name.startsWith("KEYCODE_") ? name.substring(8).replace('_', ' ') : name;
- }
-
- /** Captures the next key press (including the mouse buttons) on a focusable dialog. */
- private void openKeyboardBindCapture(InbuiltModManager modManager) {
- android.app.AlertDialog dialog = new android.app.AlertDialog.Builder(activity)
- .setTitle(R.string.mod_menu_keyboard_bind)
- .setMessage(R.string.mod_menu_bind_press_key)
- .setNegativeButton(android.R.string.cancel, null)
- .setNeutralButton(R.string.mod_menu_bind_clear, (d, w) -> {
- modManager.setModMenuKeybind(0);
- refreshKeyboardBindLabel(modManager);
- })
- .create();
- dialog.setCanceledOnTouchOutside(false);
-
- Runnable commit = () -> {
- org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay.clear();
- dialog.dismiss();
- };
- Runnable capture = () -> {
- org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay.set(keyCode -> {
- if (keyCode == android.view.KeyEvent.KEYCODE_BACK) {
- commit.run();
- return;
- }
- modManager.setModMenuKeybind(keyCode);
- refreshKeyboardBindLabel(modManager);
- commit.run();
- });
- };
- dialog.setOnDismissListener(d ->
- org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay.clear());
- // The dialog's own key listener covers the launcher-side receiver; the static capture
- // covers the in-game path where the sweetloader dispatches keys first.
- dialog.setOnKeyListener((d, keyCode, event) -> {
- if (event.getAction() != android.view.KeyEvent.ACTION_DOWN) return false;
- if (keyCode == android.view.KeyEvent.KEYCODE_BACK) {
- d.dismiss();
- return true;
- }
- modManager.setModMenuKeybind(keyCode);
- refreshKeyboardBindLabel(modManager);
- d.dismiss();
- return true;
- });
- capture.run();
- dialog.show();
- }
-
- /**
- * The controller bind picker.
- *
- * Shows the connected pad's illustration (a cached vector, not a live render, so nothing on
- * the game's frame budget is touched) and highlights the pressed button green for a moment
- * before closing, so the player sees exactly which control was captured.
- */
- private void openControllerBindPicker(InbuiltModManager modManager) {
- android.view.InputDevice device = firstGamepad();
- if (device == null) {
- android.widget.Toast.makeText(activity, R.string.mod_menu_bind_no_controller,
- android.widget.Toast.LENGTH_SHORT).show();
- return;
- }
- final org.chimeramc.client.launcher.controller.ControllerType type =
- org.chimeramc.client.launcher.controller.ControllerType.from(device) != null
- ? org.chimeramc.client.launcher.controller.ControllerType.from(device)
- : org.chimeramc.client.launcher.controller.ControllerType.XBOX;
-
- View content = LayoutInflater.from(activity).inflate(R.layout.dialog_mod_menu_bind, null);
- final org.chimeramc.client.launcher.ui.views.Controller3DView illustration =
- content.findViewById(R.id.bind_dialog_illustration);
- final TextView statusText = content.findViewById(R.id.bind_dialog_status);
- illustration.setType(type);
-
- android.app.AlertDialog dialog = new android.app.AlertDialog.Builder(activity)
- .setView(content)
- .setNegativeButton(android.R.string.cancel, null)
- .create();
- dialog.setCanceledOnTouchOutside(false);
- dialog.setOnDismissListener(d ->
- org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay.clear());
-
- content.findViewById(R.id.bind_dialog_clear).setOnClickListener(v -> {
- modManager.setModMenuControllerBind(0);
- refreshControllerBindLabel(modManager);
- dialog.dismiss();
- });
-
- // The in-game activity offers every raw key here first, so a controller press is captured
- // even when the sweetloader would otherwise consume it.
- org.chimeramc.client.core.mods.inbuilt.vip.MenuBindRelay.set(keyCode -> {
- illustration.handleKeyEvent(keyCode, true);
- String region = illustration.regionIdForKey(keyCode);
- if (region == null) return;
- illustration.setRegionConfirmed(region, true);
- illustration.animateConfirm(region);
- statusText.setText(activity.getString(R.string.mod_menu_bind_detected, bindLabel(keyCode)));
- modManager.setModMenuControllerBind(keyCode);
- refreshControllerBindLabel(modManager);
- content.postDelayed(dialog::dismiss, 550);
- });
-
- // Every key and stick movement is mirrored on the illustration; a click on a pad button is
- // what commits the bind, after which the button flashes green and the dialog closes.
- dialog.setOnKeyListener((d, keyCode, event) -> {
- illustration.handleKeyEvent(keyCode, event.getAction() == android.view.KeyEvent.ACTION_DOWN);
- if (event.getAction() != android.view.KeyEvent.ACTION_DOWN) return false;
- if (keyCode == android.view.KeyEvent.KEYCODE_BACK) {
- d.dismiss();
- return true;
- }
- String region = illustration.regionIdForKey(keyCode);
- if (region == null) return true; // a gamepad key we do not map: swallow, keep waiting
- illustration.setRegionConfirmed(region, true);
- illustration.animateConfirm(region);
- statusText.setText(activity.getString(R.string.mod_menu_bind_detected, bindLabel(keyCode)));
- modManager.setModMenuControllerBind(keyCode);
- refreshControllerBindLabel(modManager);
- android.view.View target = content;
- (target != null ? target : content).postDelayed(dialog::dismiss, 550);
- return true;
- });
-
- View.OnGenericMotionListener motionListener = (v, event) -> {
- illustration.handleMotionEvent(event);
- return true;
- };
- content.setOnGenericMotionListener(motionListener);
- if (illustration != null) {
- illustration.requestFocus();
- }
-
- dialog.show();
- }
-
- private android.view.InputDevice firstGamepad() {
- android.hardware.input.InputManager im =
- (android.hardware.input.InputManager) activity.getSystemService(android.content.Context.INPUT_SERVICE);
- if (im == null) return null;
- for (int id : im.getInputDeviceIds()) {
- android.view.InputDevice device = im.getInputDevice(id);
- if (org.chimeramc.client.launcher.controller.ControllerConnectionMonitor
- .isGamepad(device)) {
- return device;
- }
- }
- return null;
- }
-
- /**
- * Cosmetics: the player's Minecraft character plus capes and accessories.
- *
- * The panel is created lazily and owns the animated cape preview, so this method only
- * decides which section is on screen.
- */
- private void showCosmeticsSection() {
- updateNavigationItems(false, true, false, false, false, false, false);
-
- modulesContainer.setVisibility(View.GONE);
- settingsContainer.setVisibility(View.GONE);
- hidePacks();
- hideVoice();
- hideReplay();
- hideSupporterPages();
- if (cosmeticsContainer != null) {
- if (cosmeticsPanel == null) {
- cosmeticsPanel = new CosmeticsPanel(activity, compactMode);
- cosmeticsContainer.addView(cosmeticsPanel.getView(),
- new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT));
- }
- cosmeticsContainer.setVisibility(View.VISIBLE);
- // Re-resolve the skin so a change made since the Closet was last open appears now.
- cosmeticsPanel.onShown();
- crossfade(cosmeticsContainer);
- }
-
- if (overlayView != null) {
- View modConfigContainer = overlayView.findViewById(R.id.mod_config_container);
- View searchContainer = overlayView.findViewById(R.id.search_container);
- View configHeader = overlayView.findViewById(R.id.config_header);
- if (modConfigContainer != null) modConfigContainer.setVisibility(View.GONE);
- if (searchContainer != null) searchContainer.setVisibility(View.VISIBLE);
- if (configHeader != null) configHeader.setVisibility(View.GONE);
- if (filterBar != null) filterBar.setVisibility(View.GONE);
- if (compactFilterBar != null) compactFilterBar.setVisibility(View.GONE);
- }
- }
-
- private void hideCosmetics() {
- // A spinner's dropdown list is a separate window that survives hiding the container, so it
- // must be collapsed explicitly or it floats over the game after the menu closes.
- if (cosmeticsPanel != null) cosmeticsPanel.onHidden();
- if (cosmeticsContainer != null) cosmeticsContainer.setVisibility(View.GONE);
- }
-
- /**
- * Drops every panel that was attached to the previous overlay tree.
- *
- * Called from {@code setupViews} after a re-inflate. Most panels are cheap and rebuilt on
- * entry; the ones that are cached must be released so they are rebuilt against the new tree,
- * and the Replay panel additionally owns a background executor and a live recorder listener,
- * so it is disposed rather than just dropped.
- */
- private void releasePanels() {
- if (replayPanel != null) {
- replayPanel.dispose();
- replayPanel = null;
- }
- cosmeticsPanel = null;
- if (packChangerPanel != null) {
- packChangerPanel.dispose();
- packChangerPanel = null;
- }
- voicePanel = null;
- }
-
- /**
- * The in-game pack changer: the instance's resource packs with an on/off toggle each.
- *
- * Built lazily and rebuilt on entry because the pack list changes whenever the player
- * imports a pack from the launcher. Only reachable when the instance enabled it, which the
- * panel reports itself rather than hiding the tab — a hidden destination reads as a bug.
- */
- private void showPacksSection() {
- updateNavigationItems(false, false, false, false, true, false, false);
-
- modulesContainer.setVisibility(View.GONE);
- settingsContainer.setVisibility(View.GONE);
- hideCosmetics();
- hidePacks();
- hideVoice();
- hideReplay();
- hideSupporterPages();
- if (packsContainer != null) {
- packsContainer.removeAllViews();
- if (packChangerPanel != null) {
- packChangerPanel.dispose();
- }
- packChangerPanel = new PackChangerPanel(activity, compactMode);
- packsContainer.addView(packChangerPanel.getView(),
- new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT));
- packsContainer.setVisibility(View.VISIBLE);
- crossfade(packsContainer);
- }
-
- if (overlayView != null) {
- View modConfigContainer = overlayView.findViewById(R.id.mod_config_container);
- View searchContainer = overlayView.findViewById(R.id.search_container);
- View configHeader = overlayView.findViewById(R.id.config_header);
- if (modConfigContainer != null) modConfigContainer.setVisibility(View.GONE);
- if (searchContainer != null) searchContainer.setVisibility(View.GONE);
- if (configHeader != null) configHeader.setVisibility(View.VISIBLE);
- TextView configTitle = overlayView.findViewById(R.id.config_title);
- if (configTitle != null) configTitle.setText(R.string.pack_changer_title);
- if (filterBar != null) filterBar.setVisibility(View.GONE);
- if (compactFilterBar != null) compactFilterBar.setVisibility(View.GONE);
- }
- }
-
- /**
- * Voice: the proximity chat channel, its members and the public directory.
- *
- * The panel is built lazily and rebuilt on entry so it reflects the live channel state; it
- * never starts the module, which is the deliberate action behind the Mods-tab switch.
- */
- private void showVoiceSection() {
- updateNavigationItems(false, false, false, false, false, true, false);
-
- if (modulesContainer != null) modulesContainer.setVisibility(View.GONE);
- if (settingsContainer != null) settingsContainer.setVisibility(View.GONE);
- hideCosmetics();
- hidePacks();
- hideVoice();
- hideReplay();
- hideSupporterPages();
- if (voiceContainer != null) {
- voiceContainer.removeAllViews();
- voicePanel = new VoicePanel(activity, compactMode);
- voiceContainer.addView(voicePanel.getView(),
- new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT));
- voiceContainer.setVisibility(View.VISIBLE);
- crossfade(voiceContainer);
- }
-
- if (overlayView != null) {
- View modConfigContainer = overlayView.findViewById(R.id.mod_config_container);
- View searchContainer = overlayView.findViewById(R.id.search_container);
- View configHeader = overlayView.findViewById(R.id.config_header);
- if (modConfigContainer != null) modConfigContainer.setVisibility(View.GONE);
- if (searchContainer != null) searchContainer.setVisibility(View.GONE);
- if (configHeader != null) configHeader.setVisibility(View.VISIBLE);
- TextView configTitle = overlayView.findViewById(R.id.config_title);
- if (configTitle != null) configTitle.setText(R.string.voice_chat_title);
- if (filterBar != null) filterBar.setVisibility(View.GONE);
- if (compactFilterBar != null) compactFilterBar.setVisibility(View.GONE);
- }
- }
-
- /**
- * Refreshes the panel if it is the section on screen.
- *
- * Called while the menu is open so a channel joined from elsewhere, or a peer arriving, shows
- * up without reopening the menu.
- */
- void refreshVoiceSectionIfVisible() {
- if (voicePanel != null && voiceContainer != null
- && voiceContainer.getVisibility() == View.VISIBLE) {
- voicePanel.refresh();
- }
- }
-
- private void hideVoice() {
- if (voiceContainer != null) voiceContainer.setVisibility(View.GONE);
- }
-
- /**
- * Replay: the recorder controls and the clip library.
- *
- *
Built lazily and kept alive once built, because the panel owns a background executor and
- * the live recorder listener. It is the same {@link ReplayPanel} the launcher's Replay screen
- * hosts, so both surfaces show one feature rather than two implementations.
- */
- private void showReplaySection() {
- updateNavigationItems(false, false, false, false, false, false, true);
-
- if (modulesContainer != null) modulesContainer.setVisibility(View.GONE);
- if (settingsContainer != null) settingsContainer.setVisibility(View.GONE);
- hideCosmetics();
- hidePacks();
- hideVoice();
- hideSupporterPages();
- if (replayContainer != null) {
- if (replayPanel == null) {
- replayPanel = new ReplayPanel(activity, compactMode);
- replayContainer.addView(replayPanel.getView(),
- new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT));
- }
- replayPanel.setGamepadDetected(hasConnectedGamepad());
- replayContainer.setVisibility(View.VISIBLE);
- replayPanel.onShown();
- crossfade(replayContainer);
- }
-
- if (overlayView != null) {
- View modConfigContainer = overlayView.findViewById(R.id.mod_config_container);
- View searchContainer = overlayView.findViewById(R.id.search_container);
- View configHeader = overlayView.findViewById(R.id.config_header);
- if (modConfigContainer != null) modConfigContainer.setVisibility(View.GONE);
- if (searchContainer != null) searchContainer.setVisibility(View.GONE);
- if (configHeader != null) configHeader.setVisibility(View.VISIBLE);
- TextView configTitle = overlayView.findViewById(R.id.config_title);
- if (configTitle != null) configTitle.setText(R.string.replay_header_title);
- if (filterBar != null) filterBar.setVisibility(View.GONE);
- if (compactFilterBar != null) compactFilterBar.setVisibility(View.GONE);
- }
- }
-
- /** True when any device on the InputManager reports a gamepad source. */
- private boolean hasConnectedGamepad() {
- try {
- android.hardware.input.InputManager inputManager =
- (android.hardware.input.InputManager)
- activity.getSystemService(android.content.Context.INPUT_SERVICE);
- if (inputManager == null) return false;
- for (int id : inputManager.getInputDeviceIds()) {
- android.view.InputDevice device = inputManager.getInputDevice(id);
- if (device == null) continue;
- if (ControllerConnectionMonitor.isGamepad(device)) {
- return true;
- }
- }
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:2008", "Ignored a failure at ModMenuOverlay:2008", ignored);
- }
- return false;
- }
-
- private void hideReplay() {
- if (replayContainer != null) replayContainer.setVisibility(View.GONE);
- if (replayPanel != null) replayPanel.onHidden();
- }
-
- private void hidePacks() {
- if (packsContainer != null) packsContainer.setVisibility(View.GONE);
- }
-
- private void showConfigSection(UnifiedMod mod) {
- if (mod.openCustomConfig()) {
- hide();
- return;
- }
- updateNavigationItems(false, false, false, false, false, false, false);
-
- modulesContainer.setVisibility(View.GONE);
- settingsContainer.setVisibility(View.GONE);
- hideCosmetics();
- hidePacks();
- hideReplay();
- hideSupporterPages();
-
- if (overlayView != null) {
- View modConfigContainer = overlayView.findViewById(R.id.mod_config_container);
- View searchContainer = overlayView.findViewById(R.id.search_container);
- View configHeader = overlayView.findViewById(R.id.config_header);
- ViewGroup modConfigContent = overlayView.findViewById(R.id.mod_config_content);
- TextView configTitle = overlayView.findViewById(R.id.config_title);
-
- if (modConfigContainer != null) {
- modConfigContainer.setVisibility(View.VISIBLE);
- crossfade(modConfigContainer);
- }
- if (searchContainer != null) searchContainer.setVisibility(View.GONE);
- if (configHeader != null) configHeader.setVisibility(View.VISIBLE);
- if (filterBar != null) filterBar.setVisibility(View.GONE);
- if (compactFilterBar != null) compactFilterBar.setVisibility(View.GONE);
- if (configTitle != null) configTitle.setText(mod.getName());
-
- if (modConfigContent != null) {
- ModConfigView.render(activity, modConfigContent, mod, compactMode, () -> {
- InbuiltOverlayManager overlayManager = InbuiltOverlayManager.getInstance();
- if (overlayManager != null) {
- overlayManager.applyConfigurationChanges(mod.getId());
- }
- });
- }
- }
- }
-
-
- private void setupCompactFilter() {
- if (compactFilterSelector == null) return;
- compactFilterSelector.setOnClickListener(this::showCompactFilterMenu);
- updateCompactFilterSelector();
- }
-
- private void showCompactFilterMenu(View anchor) {
- PopupMenu popup = new PopupMenu(activity, anchor);
- popup.getMenu().add(0, 100, 0, R.string.filter_all);
- popup.getMenu().add(0, 101, 1, R.string.mod_menu_favorites);
- popup.getMenu().add(0, 102, 2, R.string.mod_menu_filter_enabled);
- popup.getMenu().add(0, 103, 3, R.string.mod_menu_filter_inbuilt);
- popup.getMenu().add(0, 104, 4, R.string.mod_menu_filter_external);
- popup.getMenu().add(0, 105, 5, R.string.mod_menu_filter_pvp);
- popup.getMenu().add(0, 106, 6, R.string.vip_category_visuals);
- popup.getMenu().add(0, 107, 7, R.string.vip_category_misc);
- popup.getMenu().add(0, 108, 8, R.string.mod_menu_filter_hud);
- popup.setOnMenuItemClickListener(item -> {
- switch (item.getItemId()) {
- case 101:
- setModuleFilter(ModuleFilter.FAVORITES);
- break;
- case 102:
- setModuleFilter(ModuleFilter.ENABLED);
- break;
- case 103:
- setModuleFilter(ModuleFilter.INBUILT);
- break;
- case 104:
- setModuleFilter(ModuleFilter.EXTERNAL);
- break;
- case 105:
- setModuleFilter(ModuleFilter.PVP);
- break;
- case 106:
- setModuleFilter(ModuleFilter.VISUALS);
- break;
- case 107:
- setModuleFilter(ModuleFilter.MISC);
- break;
- case 108:
- setModuleFilter(ModuleFilter.HUD);
- break;
- case 100:
- default:
- setModuleFilter(ModuleFilter.ALL);
- break;
- }
- return true;
- });
- popup.show();
- }
-
- private void setCompactMode(boolean compact) {
- compactMode = compact;
- if (modsLayoutManager != null) {
- // Two columns on a wide window, one below the 560dp threshold — the same rule the
- // layout manager is built with. A different number here would make the density toggle
- // and the initial layout disagree.
- modsLayoutManager.setSpanCount(compact ? 1 : 2);
- }
- if (adapter != null) {
- adapter.setCompactMode(compact);
- // Rebuild through the filter path rather than handing the adapter the module list
- // alone: the two-argument updateMods appends no section actions, so toggling density
- // silently dropped the ACTIONS block (Reset HUD Position / Reload Modules) until the
- // next filter change. applyFilters() recomputes both the modules and the actions for
- // the section that is on screen.
- applyFilters();
- }
- applyCompactModeLayout(compact);
- updateFilterBarVisibility();
- updateFilterButtons();
- updateModuleCount();
- }
-
- private void applyCompactModeLayout(boolean compact) {
- if (menuContainer == null) return;
- ViewGroup.LayoutParams rawParams = menuContainer.getLayoutParams();
- if (rawParams instanceof FrameLayout.LayoutParams) {
- FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) rawParams;
- if (compact) {
- int screenWidth = activity.getResources().getDisplayMetrics().widthPixels;
- int screenHeight = activity.getResources().getDisplayMetrics().heightPixels;
- int availableWidth = Math.max(1, screenWidth - dp(36));
- int desiredWidth = Math.min(dp(392), Math.round(screenWidth * 0.40f));
- int minimumWidth = Math.min(dp(312), availableWidth);
- params.width = Math.min(availableWidth, Math.max(minimumWidth, desiredWidth));
- int availableHeight = Math.max(1, screenHeight - dp(24));
- params.height = Math.min(dp(560), availableHeight);
- params.gravity = Gravity.START | Gravity.CENTER_VERTICAL;
- params.setMargins(dp(18), dp(12), 0, dp(12));
- params.setMarginStart(dp(18));
- params.setMarginEnd(0);
- } else {
- int screenWidth = activity.getResources().getDisplayMetrics().widthPixels;
- int screenHeight = activity.getResources().getDisplayMetrics().heightPixels;
- int width = Math.min(dp(UiTokens.WINDOW_MAX_WIDTH_DP),
- Math.round(screenWidth * UiTokens.WINDOW_WIDTH_FRACTION));
- params.width = Math.max(1, width);
- params.height = Math.max(1, Math.round(screenHeight * UiTokens.WINDOW_HEIGHT_FRACTION));
- params.gravity = Gravity.CENTER;
- params.setMargins(0, 0, 0, 0);
- params.setMarginStart(0);
- params.setMarginEnd(0);
- }
- menuContainer.setLayoutParams(params);
- }
-
- // The top bar keeps every destination reachable in both modes. Compact mode only
- // narrows the window; it must never hide the navigation, which is what the old
- // sidebar/compact-icon swap did once the compact icons were removed.
- boolean modulesSelected = modulesContainer != null && modulesContainer.getVisibility() == View.VISIBLE;
- boolean settingsSelected = settingsContainer != null && settingsContainer.getVisibility() == View.VISIBLE;
- boolean cosmeticsSelected = cosmeticsContainer != null && cosmeticsContainer.getVisibility() == View.VISIBLE;
- boolean packsSelected = packsContainer != null && packsContainer.getVisibility() == View.VISIBLE;
- boolean voiceSelected = voiceContainer != null && voiceContainer.getVisibility() == View.VISIBLE;
- boolean replaySelected = replayContainer != null && replayContainer.getVisibility() == View.VISIBLE;
- updateNavigationItems(modulesSelected, cosmeticsSelected, false, settingsSelected, packsSelected, voiceSelected, replaySelected);
-
- if (modsRecycler != null) {
- int padding = dp(compact ? 4 : 14);
- modsRecycler.setPadding(padding, padding, padding, padding);
- }
- menuContainer.requestLayout();
- }
-
- private void updateFilterBarVisibility() {
- boolean modulesVisible = modulesContainer != null && modulesContainer.getVisibility() == View.VISIBLE;
- if (filterBar != null) {
- filterBar.setVisibility(modulesVisible && !compactMode ? View.VISIBLE : View.GONE);
- }
- if (compactFilterBar != null) {
- compactFilterBar.setVisibility(modulesVisible && compactMode ? View.VISIBLE : View.GONE);
- }
- }
-
- private void updateCompactFilterSelector() {
- if (compactFilterSelector == null) return;
- compactFilterSelector.setText(getFilterLabelRes(activeFilter));
- compactFilterSelector.setTextColor(getAccentColor());
- }
-
- private int getFilterLabelRes(ModuleFilter filter) {
- switch (filter) {
- case FAVORITES:
- return R.string.mod_menu_favorites;
- case ENABLED:
- return R.string.mod_menu_filter_enabled;
- case INBUILT:
- return R.string.mod_menu_filter_inbuilt;
- case EXTERNAL:
- return R.string.mod_menu_filter_external;
- case PVP:
- return R.string.mod_menu_filter_pvp;
- case VISUALS:
- return R.string.vip_category_visuals;
- case MISC:
- return R.string.vip_category_misc;
- case HUD:
- return R.string.mod_menu_filter_hud;
- case ALL:
- default:
- return R.string.filter_all;
- }
- }
-
- private int dp(int value) {
- return Math.round(value * activity.getResources().getDisplayMetrics().density);
- }
-
- private void setupFilterButtons() {
- if (filterAll != null) {
- filterAll.setOnClickListener(v -> setModuleFilter(ModuleFilter.ALL));
- }
- if (filterFavorites != null) {
- filterFavorites.setOnClickListener(v -> setModuleFilter(ModuleFilter.FAVORITES));
- }
- if (filterEnabled != null) {
- filterEnabled.setOnClickListener(v -> setModuleFilter(ModuleFilter.ENABLED));
- }
- if (filterInbuilt != null) {
- filterInbuilt.setOnClickListener(v -> setModuleFilter(ModuleFilter.INBUILT));
- }
- if (filterExternal != null) {
- filterExternal.setOnClickListener(v -> setModuleFilter(ModuleFilter.EXTERNAL));
- }
- if (filterPvp != null) {
- filterPvp.setOnClickListener(v -> setModuleFilter(ModuleFilter.PVP));
- }
- if (filterVisuals != null) {
- filterVisuals.setOnClickListener(v -> setModuleFilter(ModuleFilter.VISUALS));
- }
- if (filterMisc != null) {
- filterMisc.setOnClickListener(v -> setModuleFilter(ModuleFilter.MISC));
- }
- if (filterHud != null) {
- filterHud.setOnClickListener(v -> setModuleFilter(ModuleFilter.HUD));
- }
- updateFilterButtons();
- }
-
- private void setModuleFilter(ModuleFilter filter) {
- activeFilter = filter;
- updateFilterButtons();
- applyFilters();
- }
-
- private void applyFilters() {
- filteredMods.clear();
- String query = searchInput != null
- ? searchInput.getText().toString().trim().toLowerCase(Locale.ROOT)
- : "";
-
- Map groupedMatches = new LinkedHashMap<>();
- for (UnifiedMod mod : allMods) {
- if (matchesActiveFilter(mod) && matchesQuery(mod, query)) {
- GroupedMods group = groupedMatches.computeIfAbsent(
- mod.getGroupId(), ignored -> new GroupedMods());
- group.add(mod, isFavorite(mod));
- }
- }
- for (GroupedMods group : groupedMatches.values()) {
- group.appendTo(filteredMods);
- }
-
- if (adapter != null) {
- List sectionActions = actionsForActiveSection(query);
- visibleActionCount = sectionActions.size();
- adapter.updateMods(filteredMods, favoriteKeys, sectionActions);
- // M9: the user's saved order is applied after the shipped order is loaded, so an
- // update that adds a module does not discard the arrangement the player made.
- adapter.applySavedOrder(InbuiltModManager.getInstance(activity)
- .getModMenuSectionOrder(activeFilter.name()));
- if (!hasStaggeredOnce) {
- hasStaggeredOnce = true;
- DynamicAnim.staggerRecyclerChildren(modsRecycler);
- }
- } else {
- visibleActionCount = actionsForActiveSection(query).size();
- }
- updateEmptyState();
- updateModuleCount();
- }
-
- /**
- * The one-shot actions for the section the current filter shows (M3).
- *
- * The section rule lives in {@link ModMenuActionVisibility}, so the action set cannot drift
- * from the module set it sits under. A search query narrows the actions the same way it narrows
- * the modules, matched against the action's own title and description.
- */
- private List actionsForActiveSection(String query) {
- List out = new ArrayList<>();
- for (ModMenuAction action : ModMenuActionCatalog.all()) {
- if (!ModMenuActionVisibility.isVisibleIn(activeFilter.name(), action.getGroupId())) {
- continue;
- }
- if (!query.isEmpty() && !matchesActionQuery(action, query)) {
- continue;
- }
- out.add(action);
- }
- return out;
- }
-
- /** True when the query matches the action's title or description text. */
- private boolean matchesActionQuery(ModMenuAction action, String query) {
- String title = activity.getString(action.getTitleRes());
- String subtitle = action.getSubtitleRes() != 0
- ? activity.getString(action.getSubtitleRes()) : "";
- return (title + " " + subtitle).toLowerCase(Locale.ROOT).contains(query);
- }
-
- private boolean matchesActiveFilter(UnifiedMod mod) {
- switch (activeFilter) {
- case FAVORITES:
- return isFavorite(mod);
- case ENABLED:
- return mod.isEnabled();
- case INBUILT:
- return mod.getSource() == UnifiedMod.Source.INBUILT;
- case EXTERNAL:
- return mod.getSource() == UnifiedMod.Source.EXTERNAL;
- case PVP:
- return ModIds.isPvpModule(mod.getId());
- case VISUALS:
- return ModCategory.matches(ModCategory.Tab.VISUALS, mod, null);
- case MISC:
- return ModCategory.matches(ModCategory.Tab.MISC, mod, null);
- case HUD:
- return ModIds.isHudModule(mod.getId());
- case ALL:
- default:
- return true;
- }
- }
-
- private boolean matchesQuery(UnifiedMod mod, String query) {
- if (query.isEmpty()) return true;
- String searchText = (
- safeString(mod.getName()) + " " +
- safeString(mod.getDescription()) + " " +
- safeString(mod.getId()) + " " +
- safeString(mod.getModId()) + " " +
- safeString(mod.getGroupName()) + " " +
- safeString(mod.getGroupId())
- ).toLowerCase(Locale.ROOT);
- return searchText.contains(query);
- }
-
- private boolean isFavorite(UnifiedMod mod) {
- return favoriteKeys.contains(mod.getStableKey());
- }
-
- private String safeString(String value) {
- return value != null ? value : "";
- }
-
- private void updateFilterButtons() {
- updateFilterButton(filterAll, activeFilter == ModuleFilter.ALL);
- updateFilterButton(filterFavorites, activeFilter == ModuleFilter.FAVORITES);
- updateFilterButton(filterEnabled, activeFilter == ModuleFilter.ENABLED);
- updateFilterButton(filterInbuilt, activeFilter == ModuleFilter.INBUILT);
- updateFilterButton(filterExternal, activeFilter == ModuleFilter.EXTERNAL);
- updateFilterButton(filterPvp, activeFilter == ModuleFilter.PVP);
- updateFilterButton(filterVisuals, activeFilter == ModuleFilter.VISUALS);
- updateFilterButton(filterMisc, activeFilter == ModuleFilter.MISC);
- updateFilterButton(filterHud, activeFilter == ModuleFilter.HUD);
- updateCompactFilterSelector();
- }
-
- private void updateFilterButton(TextView view, boolean selected) {
- if (view == null) return;
- view.setTextColor(selected ? getAccentColor() : 0xFFA8B0B8);
- view.setAlpha(1f);
- Drawable background = view.getBackground();
- if (background != null) {
- background.mutate().setTint(selected ? theme.accentFill(51) : 0xFF24282C);
- }
- }
-
- /**
- * Re-tints one top-bar entry for the selected/unselected state.
- *
- * Each entry is a single {@code TextView} carrying its icon as a compound drawable, so
- * tinting the compound drawable is what colours the icon — there is no separate
- * {@code ImageButton} to keep in step.
- */
- private void updateNavigationItem(TextView view, boolean selected) {
- if (view == null) return;
- int color = selected ? getAccentColor() : 0xFFA8B0B8;
- view.setTextColor(color);
- view.setAlpha(selected ? 1f : 0.82f);
- view.setCompoundDrawableTintList(ColorStateList.valueOf(color));
- view.setTypeface(view.getTypeface(), selected
- ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL);
- }
-
- /**
- * Tints every top-bar entry, selected or not.
- *
- * One explicit boolean per destination, in the layout's left-to-right order, so adding a tab
- * is a compile error at each call site rather than a silently untinted entry.
- */
- private void updateNavigationItems(boolean modules, boolean cosmetics, boolean hudEditor,
- boolean settings, boolean packs, boolean voice,
- boolean replay) {
- updateNavigationItem(navVoice, voice);
- updateNavigationItem(navGeneral, modules);
- updateNavigationItem(navCosmetics, cosmetics);
- updateNavigationItem(navHudEditor, hudEditor);
- updateNavigationItem(navSettings, settings);
- updateNavigationItem(navPacks, packs);
- updateNavigationItem(navReplay, replay);
- // The rail is the same selection in icon form. The explicit booleans above still drive the
- // (now hidden) text entries, so a caller that lights a destination also lights its rail
- // tile. Exactly one boolean is true per call site.
- if (rail != null) {
- ModMenuPage page = null;
- if (voice) page = ModMenuPage.VOICE;
- else if (modules) page = ModMenuPage.GENERAL;
- else if (cosmetics) page = ModMenuPage.COSMETICS;
- else if (hudEditor) page = ModMenuPage.HUD_EDITOR;
- else if (settings) page = ModMenuPage.SETTINGS;
- else if (packs) page = ModMenuPage.PACKS;
- else if (replay) page = ModMenuPage.REPLAY;
- if (page != null) rail.setSelected(page);
- if (page != null) updateTopBarTitle(page);
- if (page != null) activePage = page;
- }
- }
-
- /** Sets the top-bar title to the active destination, like the VIP shell's lockup. */
- private void updateTopBarTitle(ModMenuPage page) {
- if (overlayView == null || page == null) return;
- TextView title = overlayView.findViewById(R.id.mod_menu_title);
- if (title != null) {
- title.setText(page.getTitleRes());
- }
- }
-
- /** Builds the destination rail from the shared page registry. */
- private void buildRail() {
- LinearLayout railContainer = overlayView == null
- ? null : overlayView.findViewById(R.id.mod_menu_tab_bar);
- if (railContainer == null) return;
- rail = new ModMenuRail(activity, railContainer, getAccentColor());
- rail.build(this::selectPage);
- }
-
- /**
- * Fills the top-bar input-mode badge from the connected input, so the shell reads the same as
- * the VIP menu's. A pad present shows the controller badge, otherwise the keyboard badge.
- */
- private void determineInputMode() {
- if (overlayView == null) return;
- TextView badge = overlayView.findViewById(R.id.mod_menu_mode_badge);
- if (badge == null) return;
- boolean controller = hasConnectedGamepad();
- badge.setText(controller ? R.string.vip_mode_controller : R.string.vip_mode_keyboard);
- badge.setCompoundDrawablesRelativeWithIntrinsicBounds(
- controller ? R.drawable.ic_controller : R.drawable.ic_keyboard, 0, 0, 0);
- }
-
- /** Dispatches a rail selection to the matching section. The single source of tab routing. */
- private void selectPage(ModMenuPage page) {
- if (page == null) return;
- // The active page is recorded here, not only in updateNavigationItems: a page that passes
- // all-false booleans (Controller, Keyboard) still has to be the active one, or the
- // shoulder cycle and the controller-input routing would not know where they are.
- activePage = page;
- if (rail != null) rail.setSelected(page);
- updateTopBarTitle(page);
- switch (page) {
- case GENERAL:
- showModulesSection();
- break;
- case VOICE:
- showVoiceSection();
- break;
- case PACKS:
- showPacksSection();
- break;
- case COSMETICS:
- showCosmeticsSection();
- break;
- case HUD_EDITOR:
- showHudEditorSection();
- break;
- case REPLAY:
- showReplaySection();
- break;
- case SETTINGS:
- showSettingsSection();
- break;
- case CONTROLLER:
- showControllerSection();
- break;
- case KEYBOARD:
- showKeyboardSection();
- break;
- default:
- break;
- }
- }
-
- /**
- * The Controller page (Supporter): profiles, dead zone, sensitivity and rumble, plus the
- * shared full editor. It was the VIP window's own tab; with one menu it is an ordinary page.
- */
- private void showControllerSection() {
- updateNavigationItems(false, false, false, false, false, false, false);
- hideAllContentExcept(controllerContainer);
- if (controllerContainer == null) return;
- if (controllerSection == null) {
- controllerSection = new ModMenuControllerSection(activity, controllerContainer,
- getAccentColor());
- controllerSection.build();
- controllerSection.setOnSectionChanged(this::refreshMods);
- }
- controllerContainer.setVisibility(View.VISIBLE);
- controllerSection.refresh();
- crossfade(controllerContainer);
- }
-
- /** The Keyboard page (Supporter): the drawn board, the rebindable actions, pointer settings. */
- private void showKeyboardSection() {
- updateNavigationItems(false, false, false, false, false, false, false);
- hideAllContentExcept(keyboardContainer);
- if (keyboardContainer == null) return;
- if (keyboardSection == null) {
- keyboardSection = new ModMenuKeyboardSection(activity, keyboardContainer,
- getAccentColor());
- keyboardSection.build();
- }
- keyboardContainer.setVisibility(View.VISIBLE);
- keyboardSection.refresh();
- crossfade(keyboardContainer);
- }
-
- /**
- * Hides every content host except {@code keep}.
- *
- *
The page routing used to hand-hide each host at each call site, so adding a page meant
- * remembering every one of them. One helper means a new host is added in exactly one place.
- */
- private void hideAllContentExcept(View keep) {
- // Hiding a host is not always enough: a Cosmetics spinner's list is a separate window and a
- // Replay panel owns a live recorder listener, so both need their own onHidden() when their
- // host goes away. Running it here means every page switch is correct by construction.
- if (keep != cosmeticsContainer) hideCosmetics();
- if (keep != replayContainer) hideReplay();
- if (keep != voiceContainer) hideVoice();
- // The keyboard page arms a key-capture timeout; leaving the page must cancel it, or it
- // fires into a detached tree and holds the section alive.
- if (keep != keyboardContainer && keyboardSection != null) keyboardSection.onHidden();
- if (keep != packsContainer && packsContainer != null) {
- packsContainer.setVisibility(View.GONE);
- }
- for (View host : new View[]{modulesContainer, settingsContainer, cosmeticsContainer,
- packsContainer, voiceContainer, replayContainer, controllerContainer,
- keyboardContainer}) {
- if (host != null && host != keep) host.setVisibility(View.GONE);
- }
- if (overlayView != null) {
- View modConfigContainer = overlayView.findViewById(R.id.mod_config_container);
- if (modConfigContainer != null && modConfigContainer != keep) {
- modConfigContainer.setVisibility(View.GONE);
- }
- }
- }
-
- /** Hides the two Supporter pages' hosts, for the pages that hide their siblings by hand. */
- private void hideSupporterPages() {
- if (controllerContainer != null) controllerContainer.setVisibility(View.GONE);
- if (keyboardContainer != null) keyboardContainer.setVisibility(View.GONE);
- }
-
- /** Enters HUD-editor mode from the rail (the top bar no longer carries that destination). */
- private void showHudEditorSection() {
- if (overlayView == null) return;
- if (menuContainer != null && hudEditor != null && hudEditor.toolsView() != null) {
- hudEditor.enter(menuContainer);
- }
- }
-
- private void updateModuleCount() {
- String value = activity.getString(
- R.string.mod_menu_module_count,
- filteredMods.size(),
- allMods.size());
- if (moduleCountText != null) {
- moduleCountText.setText(value);
- }
- if (compactModuleCount != null) {
- compactModuleCount.setText(value);
- }
- }
-
- private void loadMods() {
- allMods.clear();
-
- InbuiltModManager manager = InbuiltModManager.getInstance(activity);
- favoriteKeys.clear();
- favoriteKeys.addAll(manager.getFavoriteModKeys());
- allMods.addAll(InbuiltModuleProvider.load(activity));
- allMods.addAll(ExternalModuleProvider.load(activity));
-
- applyRowStates(manager);
- applyFilters();
- }
-
- /**
- * Derives each module's row state and hands it to the adapter (M10).
- *
- *
This is what makes the menu self-explaining: a module the crash guard disabled says so,
- * and a module with no hook on this build says that instead of looking like a plain off
- * toggle. The state is derived from the same sources the rest of the launcher uses, so the
- * menu cannot claim a module is healthy when the crash guard has disabled it.
- */
- private void applyRowStates(InbuiltModManager manager) {
- if (adapter == null) return;
- java.util.Map states = new java.util.HashMap<>();
- for (UnifiedMod mod : allMods) {
- boolean available = org.chimeramc.client.core.mods.inbuilt.model.ModAvailability
- .isInteractive(mod.getId());
- // A recorded load failure is what "failed to register" means in this build; there is
- // no separate registration flag on the row model.
- boolean registered = org.chimeramc.client.core.mods.ModLoadDiagnostics
- .getForMod(activity, mod.getId()) == null;
- boolean autoDisabled = false;
- try {
- autoDisabled = org.chimeramc.client.core.mods.ModCrashAttribution
- .wasAutoDisabled(activity, mod.getId());
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:2890", "Ignored a failure at ModMenuOverlay:2890", ignored);
- }
- states.put(mod.getStableKey(), ModMenuRowState.of(
- registered, available, autoDisabled, mod.isEnabled()));
- }
- adapter.setRowStates(states);
- }
-
- private void filterMods(String query) {
- applyFilters();
- }
-
- private void updateEmptyState() {
- // The action rows count as content: an empty module list with "Reset HUD Position" still on
- // screen is not an empty screen, and the "no mods" notice over it reads as a bug.
- boolean hasContent = !filteredMods.isEmpty() || visibleActionCount > 0;
- if (emptyState != null) {
- emptyState.setVisibility(hasContent ? View.GONE : View.VISIBLE);
- }
- if (emptyStateText != null) {
- String query = searchInput != null ? searchInput.getText().toString().trim() : "";
- if (!query.isEmpty()) {
- emptyStateText.setText(R.string.mod_menu_no_matches);
- } else if (activeFilter == ModuleFilter.FAVORITES) {
- emptyStateText.setText(R.string.mod_menu_no_favorites);
- } else if (activeFilter == ModuleFilter.PVP) {
- // The positioning statement comes from PvpSectionPolicy, the same rule the docs and
- // the release notes read, so the screen cannot describe the section differently
- // from the policy that bounds it.
- emptyStateText.setText(PvpSectionPolicy.positioning());
- } else {
- emptyStateText.setText(R.string.mod_menu_no_mods);
- }
- }
- }
-
- public void refreshMods() {
- loadMods();
- }
-
- /**
- * Applies the player's two independent opacities to the menu window.
- *
- * The container's alpha is the background opacity; the accent surfaces inside (the rail's
- * selection, the enabled cards, the filter chips) read the primary opacity from the theme, so a
- * clear panel with a solid accent — or the reverse — is expressible. Setting the container alpha
- * alone was the whole of the old behaviour, which is why the two could not move separately.
- */
- private void applyMenuOpacity() {
- if (overlayView == null) return;
- View menuContainer = overlayView.findViewById(R.id.mod_menu_container);
- if (menuContainer == null) return;
- // Re-resolve the theme so a change to the accent or its opacity redraws the accent surfaces
- // without recreating the overlay. The menu's own settings are passed in: building the theme
- // from the theme-state alone silently dropped the theme preset, the density, the radius and
- // the HUD opacity, so any live change reset them to their defaults.
- theme = new ModMenuTheme(activity,
- menuSettings != null ? menuSettings : InbuiltModManager.getInstance(activity)
- .getModMenuSettings());
- // applyThemeToSurfaces owns the container alpha now: the menu-opacity control and the
- // HUD-opacity setting are two multipliers on the same view, and setting it in two places is
- // how one of them silently stops working.
- applyThemeToSurfaces();
- }
-
- /**
- * Re-tints the accent surfaces from the current theme.
- *
- *
The rail's selection, the filter chips and the enabled cards all derive their colour from
- * the accent, so they must follow a theme change; leaving them on the value they were built with
- * is how a live colour change "only half applies".
- */
- private void applyThemeToSurfaces() {
- if (overlayView == null || theme == null) return;
- try {
- applyPanelSurfaces();
- // The rail bakes the accent into its icons and pills when it is built, so a colour
- // change means rebuilding it rather than patching each tile. Only rebuild a rail that
- // already exists — at first setup the rail is built later, by its own path.
- if (rail != null) {
- buildRail();
- if (activePage != null) rail.setSelected(activePage);
- }
- if (compactFilterSelector != null) {
- compactFilterSelector.setTextColor(getAccentColor());
- }
- // The adapter carries the theme, so a re-bind repaints every card in the new accent.
- if (adapter != null) {
- adapter.setTheme(theme);
- }
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:2981", "Ignored a failure at ModMenuOverlay:2981", ignored);
- }
- }
-
- /**
- * Paints the panel, the scrim and the HUD opacity from the resolved theme (M6).
- *
- *
Three settings met here and none of them previously reached a pixel: the theme preset (the
- * panel's fill came from the layout's fixed {@code mod_menu_bg_surface1}), the scrim (fixed in
- * the layout) and the HUD-opacity slider. The glass intensity reaches the panel through
- * {@link #applyPanelBackdrop()} — a blurred layer behind the panel — and through the panel's own
- * fill, so glass on is a real material rather than an opaque card.
- */
- private void applyPanelSurfaces() {
- View container = overlayView.findViewById(R.id.mod_menu_container);
- ModMenuSettings.GlassIntensity glass = effectivePanelGlass();
- if (container instanceof androidx.cardview.widget.CardView) {
- androidx.cardview.widget.CardView cv = (androidx.cardview.widget.CardView) container;
- cv.setCardBackgroundColor(ModMenuPalette.panelFill(theme.scheme(), glass));
- cv.setRadius(theme.cornerRadiusDp() * density());
- }
- View scrim = overlayView.findViewById(R.id.mod_menu_scrim);
- if (scrim != null) {
- // The glass intensity drives how much of the game the scrim lets through: full glass
- // keeps it light, solid hides the game behind the panel. Never fully transparent, or the
- // menu text lands directly on the game with no separation.
- float scrimAlpha = scrimAlphaFor(glass);
- scrim.setBackgroundColor(ModMenuPalette.blend(0x00000000, theme.scrimColor(),
- scrimAlpha));
- }
- applyPanelBackdrop(glass);
- // The HUD-opacity slider is a multiplier on the whole panel, independent of the panel's own
- // alpha (which the menu-opacity control owns).
- if (container != null) {
- container.setAlpha(menuOpacity() * theme.hudOpacity());
- }
- }
-
- /**
- * The glass intensity actually in effect (M6).
- *
- *
The player's in-menu choice is filtered through the accessibility switches: "reduce motion"
- * (which the app folds the OS reduce-transparency signal into) and the platform's
- * "animations off" scale both force the solid surface. A user-facing override that beats the OS
- * accessibility setting is a bug, so the OS always wins here — the in-menu value is only the
- * starting point.
- */
- private ModMenuSettings.GlassIntensity effectivePanelGlass() {
- if (menuSettings == null) return ModMenuSettings.GlassIntensity.OFF;
- boolean reduce = false;
- boolean animationsOff = false;
- try {
- reduce = new org.chimeramc.client.util.PersonalizationManager(activity)
- .isReduceMotion();
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:3036", "Ignored a failure at ModMenuOverlay:3036", ignored);
- }
- try {
- animationsOff = android.provider.Settings.Global.getFloat(
- activity.getContentResolver(),
- android.provider.Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f;
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:3043", "Ignored a failure at ModMenuOverlay:3043", ignored);
- }
- return menuSettings.effectiveGlass(reduce, animationsOff);
- }
-
- /**
- * Captures the game behind the panel and blurs it onto the backdrop layer (M4).
- *
- *
Reuses the wheel's capture machinery: a 1/4-resolution {@code PixelCopy} of the launcher's
- * window, then either a {@code RenderEffect} on the layer (API 31+) or a downscale/upscale blit
- * (API 28-30). The capture is taken once when the menu opens and then refreshed at the configured
- * blur rate while it is open — never per frame, because the panel is drawn over a live game and a
- * per-frame blur is the fastest way to lose the frame budget on a mid-range GPU.
- *
- *
Everything here is fail-open: no capture, no {@code PixelCopy} support, or a failed blit
- * leaves the layer hidden and the panel shows its opaque-ish fill, which is what
- * {@link ModMenuTheme#panelFill()} already accounts for.
- */
- private void applyPanelBackdrop(ModMenuSettings.GlassIntensity glass) {
- android.widget.ImageView layer = overlayView == null ? null
- : overlayView.findViewById(R.id.mod_menu_backdrop);
- if (layer == null) return;
- if (glass == ModMenuSettings.GlassIntensity.OFF
- || !WheelBackdropCapture.isSupported()) {
- releasePanelBackdrop(layer);
- return;
- }
- if (backdropCapture == null) backdropCapture = new WheelBackdropCapture();
- if (backdropBitmap != null && !backdropBitmap.isRecycled()) {
- showBackdropBitmap(layer, backdropBitmap);
- return;
- }
- // No capture yet: ask for one. The callback re-enters here and shows it.
- backdropCapture.captureIfDue(activity.getWindow(),
- android.os.SystemClock.uptimeMillis(), menuSettings.blurFps(),
- new WheelBackdropCapture.Callback() {
- @Override
- public void onCaptured(android.graphics.Bitmap capture) {
- if (overlayView == null || capture == null) return;
- backdropBitmap = capture;
- android.widget.ImageView view = overlayView.findViewById(R.id.mod_menu_backdrop);
- if (view != null) showBackdropBitmap(view, capture);
- }
-
- @Override
- public void onFailed() {
- // Keep the previous capture; the panel stays on its solid fill.
- }
- });
- }
-
- /**
- * Puts a capture on the backdrop layer, blurred (M4).
- *
- *
Two routes, chosen by API level exactly as the wheel's are. The blurred bitmap for the
- * pre-31 route is cached on the overlay so a refresh reuses the same allocation; it is recycled
- * when a new capture replaces it.
- */
- private void showBackdropBitmap(android.widget.ImageView layer, android.graphics.Bitmap capture) {
- try {
- layer.setVisibility(android.view.View.VISIBLE);
- if (ApiLevels.atLeastS()) {
- layer.setImageBitmap(capture);
- // The blur radius is the regular tier's, matching the wheel's panel backdrop. The
- // RenderEffect type is reached only through the renderer's guarded holder.
- glassRenderer.applyLayerBlur(layer, RadialWheelMaterial.REGULAR_BLUR_DP * density());
- } else {
- // Below 31 there is no RenderEffect: blur by blitting the capture down and back up.
- android.graphics.Bitmap blurred = glassRenderer.stackBlurCopy(capture);
- if (blurred == null) {
- releasePanelBackdrop(layer);
- return;
- }
- android.graphics.Bitmap previous = backdropBlurred;
- backdropBlurred = blurred;
- layer.setImageBitmap(blurred);
- recycleQuietly(previous);
- }
- } catch (Throwable ignored) {
- releasePanelBackdrop(layer);
- }
- }
-
- private void releasePanelBackdrop(android.widget.ImageView layer) {
- if (layer != null) {
- glassRenderer.clearViewEffect(layer);
- try {
- layer.setImageDrawable(null);
- layer.setVisibility(android.view.View.GONE);
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:3133", "Ignored a failure at ModMenuOverlay:3133", ignored);
- }
- }
- recycleQuietly(backdropBlurred);
- backdropBlurred = null;
- backdropBitmap = null;
- if (backdropCapture != null) {
- backdropCapture.release();
- backdropCapture = null;
- }
- }
-
- private static void recycleQuietly(android.graphics.Bitmap bitmap) {
- if (bitmap == null || bitmap.isRecycled()) return;
- try {
- bitmap.recycle();
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:3150", "Ignored a failure at ModMenuOverlay:3150", ignored);
- }
- }
-
- /** The menu-opacity control's value (0..1), the panel's own alpha. */
- private float menuOpacity() {
- if (activity == null) return 1f;
- int opacity = InbuiltModManager.getInstance(activity).getModMenuOpacity();
- return Math.max(0f, Math.min(1f, opacity / 100f));
- }
-
- /**
- * The scrim opacity for a glass intensity (M6).
- *
- *
Pure so the three steps are pinned by a test. More glass means less scrim: a fully opaque
- * scrim behind a "Full" blur would hide the very thing the blur is sampling.
- */
- static float scrimAlphaFor(ModMenuSettings.GlassIntensity intensity) {
- if (intensity == null) return SCRIM_ALPHA_FULL;
- switch (intensity) {
- case OFF:
- return SCRIM_ALPHA_OFF;
- case SUBTLE:
- return SCRIM_ALPHA_SUBTLE;
- case FULL:
- default:
- return SCRIM_ALPHA_FULL;
- }
- }
-
- /** The scrim opacity when glass is off (solid panel, most separation). */
- static final float SCRIM_ALPHA_OFF = 0.85f;
- /** The scrim opacity for subtle glass. */
- static final float SCRIM_ALPHA_SUBTLE = 0.62f;
- /** The scrim opacity for full glass (lightest, so the blur has something to sample). */
- static final float SCRIM_ALPHA_FULL = 0.40f;
-
- public void hide() {
- if (!isShowing || overlayView == null) return;
-
- handler.removeCallbacks(statsTick);
- handler.removeCallbacks(backdropTick);
- // Release the panel's backdrop capture and its blurred copy: the layer is about to be
- // detached, and a capture held past that point keeps a full-window bitmap alive for nothing.
- releasePanelBackdrop(overlayView.findViewById(R.id.mod_menu_backdrop));
- if (renderDebugOverlay != null) {
- renderDebugOverlay.stop();
- renderDebugOverlay = null;
- }
- if (replayPanel != null) replayPanel.onHidden();
- // Collapse any open cosmetics dropdown before the tree is removed; a spinner's list is a
- // separate window and would otherwise stay floating over the game after the menu closes.
- if (cosmeticsPanel != null) cosmeticsPanel.onHidden();
-
- InbuiltOverlayManager overlayManager = InbuiltOverlayManager.getInstance();
- if (overlayManager != null) {
- overlayManager.setHudEditorMode(false);
- overlayManager.setHudEditorSelectionListener(null);
- }
-
- Runnable performHide = () -> {
- handler.post(() -> {
- try {
- if (wmParams != null && windowManager != null) {
- windowManager.removeView(overlayView);
- } else {
- ViewGroup rootView = activity.findViewById(android.R.id.content);
- if (rootView != null) {
- rootView.removeView(overlayView);
- }
- }
- } catch (Exception ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuOverlay:3222", "Ignored a failure at ModMenuOverlay:3222", ignored);
- }
- overlayView = null;
- isShowing = false;
- hasStaggeredOnce = false;
- });
- };
-
- View menuContainer = overlayView.findViewById(R.id.mod_menu_container);
- if (menuContainer != null) {
- animateMenuExit(menuContainer, performHide);
- } else {
- performHide.run();
- }
- overlayView.animate().alpha(0f).setDuration(180).start();
- }
-
- /**
- * Routes one controller key press into the open menu: a d-pad/left-stick direction moves the
- * selection, a face/shoulder button toggles the selected module.
- *
- *
Called from the game activity before the sweetloader sees the key, because the overlay root
- * is unfocusable on purpose (see {@link #disableFocusHighlight}) — so the framework cannot move
- * focus for us and the direction has to be applied to the list directly.
- *
- * @return true when the press was used by the menu and must be swallowed
- */
- public boolean handleControllerKey(int keyCode, boolean down) {
- if (!isShowing || !down) return false;
- // The Controller page owns the raw press while it is showing: the illustration highlights
- // the physically pressed button, and a modifier capture in the full editor takes the next
- // press. This is why the page must be offered the key before any navigation handling.
- if (activePage == ModMenuPage.CONTROLLER && controllerSection != null) {
- if (controllerSection.isCapturingModifier()
- && controllerSection.handleCaptureKey(keyCode)) {
- return true;
- }
- controllerSection.handleHardwareKey(keyCode);
- }
- // The Replay section owns input while it is on screen: its buttons (record, cards) are
- // touch-driven and controller input cycles the selection and acts on it.
- if (replayPanel != null && replayContainer != null
- && replayContainer.getVisibility() == View.VISIBLE) {
- return handleReplayKey(keyCode);
- }
- switch (keyCode) {
- case android.view.KeyEvent.KEYCODE_BUTTON_L1:
- case android.view.KeyEvent.KEYCODE_BUTTON_L2:
- selectPage(ModMenuPage.previous(activePage == null
- ? ModMenuPage.GENERAL : activePage));
- return true;
- case android.view.KeyEvent.KEYCODE_BUTTON_R1:
- case android.view.KeyEvent.KEYCODE_BUTTON_R2:
- selectPage(ModMenuPage.next(activePage == null
- ? ModMenuPage.GENERAL : activePage));
- return true;
- default:
- break;
- }
- if (adapter == null) return false;
- switch (keyCode) {
- case android.view.KeyEvent.KEYCODE_DPAD_UP:
- return adapter.handleControllerNavigation(ModMenuNavigation.Direction.UP);
- case android.view.KeyEvent.KEYCODE_DPAD_DOWN:
- return adapter.handleControllerNavigation(ModMenuNavigation.Direction.DOWN);
- case android.view.KeyEvent.KEYCODE_DPAD_LEFT:
- return adapter.handleControllerNavigation(ModMenuNavigation.Direction.LEFT);
- case android.view.KeyEvent.KEYCODE_DPAD_RIGHT:
- return adapter.handleControllerNavigation(ModMenuNavigation.Direction.RIGHT);
- case android.view.KeyEvent.KEYCODE_BUTTON_A:
- case android.view.KeyEvent.KEYCODE_ENTER:
- return adapter.handleControllerSelect();
- case android.view.KeyEvent.KEYCODE_BUTTON_START:
- hide();
- return true;
- default:
- return false;
- }
- }
-
- /**
- * Controller handling for the Replay section.
- *
- *
Every binding the on-screen hint names is resolved through {@link ReplayControls} and
- * applied by {@link ReplayPanel#handleControl}, so the in-game menu and the Library's Replay tab
- * run one implementation and cannot drift. Only the cinematic-camera button is a menu-only
- * extra, because the Library does not offer that route.
- */
- private boolean handleReplayKey(int keyCode) {
- ReplayControls.Action action = ReplayControls.forControllerKey(keyCode);
- if (action != null) {
- // Start is the transport play/pause only while a clip is open; otherwise it is not
- // consumed here, so the caller can fall back to its own binding.
- if (action == ReplayControls.Action.PLAY_PAUSE && !replayPanel.isPlayerOpen()) {
- return false;
- }
- return replayPanel.handleControl(action);
- }
- switch (keyCode) {
- case android.view.KeyEvent.KEYCODE_ENTER:
- case android.view.KeyEvent.KEYCODE_DPAD_CENTER:
- replayPanel.playSelected();
- return true;
- case android.view.KeyEvent.KEYCODE_BUTTON_THUMBR:
- replayPanel.cinematicSelected();
- return true;
- default:
- return false;
- }
- }
-
- /**
- * Routes analogue stick movement into the open menu, so the selection can be moved with the
- * stick and not only the d-pad.
- *
- *
A repeat delay is applied because a held stick would otherwise race through the list.
- *
- * @return true when the movement was used by the menu and must be swallowed
- */
- public boolean handleControllerMotion(android.view.MotionEvent event) {
- if (!isShowing) return false;
- if (activePage == ModMenuPage.CONTROLLER && controllerSection != null) {
- controllerSection.handleHardwareMotion(event);
- return false;
- }
- if (adapter == null) return false;
- float axisX = event.getAxisValue(android.view.MotionEvent.AXIS_X);
- float axisY = event.getAxisValue(android.view.MotionEvent.AXIS_Y);
- if (Math.abs(axisX) < NAV_AXIS_THRESHOLD && Math.abs(axisY) < NAV_AXIS_THRESHOLD) return false;
-
- long now = android.os.SystemClock.uptimeMillis();
- if (now < nextAxisNavMs) return true;
- nextAxisNavMs = now + NAV_AXIS_REPEAT_MS;
-
- if (Math.abs(axisX) > Math.abs(axisY)) {
- return adapter.handleControllerNavigation(axisX > 0
- ? ModMenuNavigation.Direction.RIGHT : ModMenuNavigation.Direction.LEFT);
- }
- return adapter.handleControllerNavigation(axisY > 0
- ? ModMenuNavigation.Direction.DOWN : ModMenuNavigation.Direction.UP);
- }
-
- private static final float NAV_AXIS_THRESHOLD = 0.6f;
- private static final long NAV_AXIS_REPEAT_MS = 180L;
- private volatile long nextAxisNavMs = 0L;
-
- public boolean isShowing() {
- return isShowing;
- }
-
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuPage.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuPage.java
deleted file mode 100644
index 57287d1d..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuPage.java
+++ /dev/null
@@ -1,97 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import org.chimeramc.client.R;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * The one page registry the Mod Menu is built from.
- *
- *
There is a single Mod Menu now (U4). The touch screen reached from the launcher and the
- * bind-opened screen used to be two separate windows with two page lists; that is why a module or a
- * destination could exist in one and not the other. They are one screen with one registry, and the
- * pages that used to be premium-only (Controller, Keyboard) are ordinary pages marked
- * {@link #isSupporter()} — the badge is what marks them, not a second window.
- *
- *
This enum is the pure identity of a page (id, title, icon, supporter flag). The
- * {@code build(view)} / {@code onShow()} / {@code onHide()} behaviour stays in the overlay, because
- * it needs Android views; the registry is deliberately Android-light (resource ids only) so the
- * ordering and badge rules are unit-tested without a device.
- */
-public enum ModMenuPage {
- GENERAL(R.string.mod_menu_general, R.drawable.ic_modules, false),
- VOICE(R.string.nav_voice, R.drawable.ic_nav_voice, false),
- PACKS(R.string.mod_menu_packs, R.drawable.ic_content, false),
- COSMETICS(R.string.mod_menu_cosmetics, R.drawable.ic_tshirt, false),
- HUD_EDITOR(R.string.mod_menu_hud_editor, R.drawable.ic_edit, false),
- REPLAY(R.string.nav_replay, R.drawable.ic_replay, false),
- SETTINGS(R.string.settings, R.drawable.ic_settings, false),
- // The Supporter pages. They are the same menu, the same rail and the same input routing as any
- // other page; the badge is the only difference, so a Supporter page cannot be missing from the
- // menu the way a VIP-only page could be.
- CONTROLLER(R.string.vip_tab_controller, R.drawable.ic_controller, true),
- KEYBOARD(R.string.vip_tab_keyboard, R.drawable.ic_keyboard, true);
-
- private final int titleRes;
- private final int iconRes;
- private final boolean supporter;
-
- ModMenuPage(int titleRes, int iconRes, boolean supporter) {
- this.titleRes = titleRes;
- this.iconRes = iconRes;
- this.supporter = supporter;
- }
-
- public int getTitleRes() {
- return titleRes;
- }
-
- public int getIconRes() {
- return iconRes;
- }
-
- /** True when the page carries the Supporter badge. */
- public boolean isSupporter() {
- return supporter;
- }
-
- /** Every page, in the order the rail shows them and the shoulders cycle through them. */
- public static List pages() {
- List out = new ArrayList<>();
- for (ModMenuPage page : values()) out.add(page);
- return out;
- }
-
- /** The pages that carry the Supporter badge, in order. */
- public static List supporterPages() {
- List out = new ArrayList<>();
- for (ModMenuPage page : values()) {
- if (page.supporter) out.add(page);
- }
- return out;
- }
-
- /** The page after {@code page}, wrapping. */
- public static ModMenuPage next(ModMenuPage page) {
- List pages = pages();
- int i = pages.indexOf(page);
- return pages.get((i + 1 + pages.size()) % pages.size());
- }
-
- /** The page before {@code page}, wrapping. */
- public static ModMenuPage previous(ModMenuPage page) {
- List pages = pages();
- int i = pages.indexOf(page);
- return pages.get((i - 1 + pages.size()) % pages.size());
- }
-
- /**
- * The page the menu opens on for a given input family. A pad user lands on the Controller
- * surface and a keyboard user on the Keyboard surface, so the relevant input page is one press
- * away; the module grid stays a shoulder press away either way.
- */
- public static ModMenuPage defaultForInput(boolean hasGamepad) {
- return hasGamepad ? CONTROLLER : KEYBOARD;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuPalette.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuPalette.java
index 95bc3a23..a76bd1ae 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuPalette.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuPalette.java
@@ -24,6 +24,11 @@
*/
public final class ModMenuPalette {
+ /** The glass fill alpha (0..255) shared by every translucent panel. */
+ public static final int GLASS_FILL_ALPHA = 0x8C;
+ /** The white tint shared by every translucent panel. */
+ public static final float GLASS_TINT = 0.14f;
+
private ModMenuPalette() {
}
@@ -148,16 +153,16 @@ public static int withAlpha(int color, int alpha) {
/**
* The panel's fill for a glass intensity (M4).
*
- * Reuses the wheel's own regular tier — the same fill alpha and the same white tint — so the
- * Mod Menu panel and the radial wheel are the same material rather than two similar ones. A
- * solid intensity returns the opaque surface: with no blur behind it, a translucent panel would
- * just show the game through the menu's own text.
+ *
Uses the shared glass tier — one fill alpha and one white tint — so every translucent panel
+ * in the client is the same material rather than two similar ones. A solid intensity returns the
+ * opaque surface: with no blur behind it, a translucent panel would just show the game through
+ * the menu's own text.
*/
public static int panelFill(Scheme scheme, ModMenuSettings.GlassIntensity glass) {
if (scheme == null) return BG_SURFACE_1;
if (glass == null || glass == ModMenuSettings.GlassIntensity.OFF) return scheme.surface1;
- int base = withAlpha(scheme.surface1, RadialWheelMaterial.REGULAR_FILL_ALPHA);
- return blend(base, 0xFFFFFFFF, RadialWheelMaterial.REGULAR_TINT);
+ int base = withAlpha(scheme.surface1, GLASS_FILL_ALPHA);
+ return blend(base, 0xFFFFFFFF, GLASS_TINT);
}
private static int clampChannel(int value) {
@@ -180,18 +185,6 @@ public static int mutedTrack() {
return (alpha << 24) | (ACCENT_MUTED & 0x00FFFFFF);
}
- /** The state dot colour for a row's load state (M10). */
- public static int dotFor(ModMenuRowState state) {
- switch (state) {
- case FAILED:
- case CRASHED:
- return STATE_ERROR;
- case UNSUPPORTED:
- return STATE_WARN;
- default:
- return 0;
- }
- }
/** The tint laid over the glass for a glass-intensity step (M4). */
public static float tintFor(ModMenuSettings.GlassIntensity intensity) {
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRail.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRail.java
deleted file mode 100644
index 95a0d09e..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRail.java
+++ /dev/null
@@ -1,149 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.app.Activity;
-import android.view.Gravity;
-import android.view.View;
-import android.widget.FrameLayout;
-import android.widget.ImageView;
-import android.widget.LinearLayout;
-import android.widget.Toast;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.ui.components.UiTokens;
-
-import java.util.EnumMap;
-import java.util.Map;
-import java.util.function.Consumer;
-
-/**
- * The touch Mod Menu's icon rail.
- *
- *
The rail is built from {@link ModMenuPage#pages()}, the single page registry the whole menu
- * reads, so a new destination is one enum entry. A page flagged
- * {@link ModMenuPage#isSupporter()} gets a small accent dot on its tile, which is the Supporter
- * badge: the page is otherwise an ordinary page of the same menu (U4).
- *
- *
This class owns only view construction and the selection fill; which page is currently shown
- * stays with the overlay, which passes a selection callback.
- */
-final class ModMenuRail {
-
- private final Activity activity;
- private final LinearLayout container;
- private final Map tiles = new EnumMap<>(ModMenuPage.class);
- private final Map icons = new EnumMap<>(ModMenuPage.class);
- private final int accent;
- private ModMenuPage selected;
-
- ModMenuRail(Activity activity, LinearLayout container, int accent) {
- this.activity = activity;
- this.container = container;
- this.accent = accent;
- }
-
- /** Builds one tile per shared page. Idempotent: an existing rail is cleared first. */
- void build(Consumer onSelect) {
- if (container == null) return;
- container.removeAllViews();
- tiles.clear();
- icons.clear();
-
- int size = (int) (UiTokens.RAIL_BUTTON * density());
- int gap = (int) (8 * density());
- // The bar may be a vertical rail or a horizontal bottom bar; the gap goes on the axis the
- // tiles are laid out on, so a horizontal bar spaces them sideways instead of stacking them.
- boolean horizontal = container.getOrientation() == LinearLayout.HORIZONTAL;
- for (final ModMenuPage page : ModMenuPage.pages()) {
- // A fixed-size tile with a centred icon, matching the VIP rail. A TextView with a
- // compound drawable positions the icon against an empty text line box, so it drifts
- // off centre at some densities.
- FrameLayout tile = new FrameLayout(activity);
- LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(size, size);
- if (horizontal) {
- lp.rightMargin = gap;
- } else {
- lp.bottomMargin = gap;
- }
- tile.setLayoutParams(lp);
- tile.setClickable(true);
- tile.setFocusable(false);
- tile.setContentDescription(activity.getString(page.getTitleRes()));
-
- ImageView icon = new ImageView(activity);
- int iconSize = (int) (22 * density());
- FrameLayout.LayoutParams iconLp =
- new FrameLayout.LayoutParams(iconSize, iconSize);
- iconLp.gravity = Gravity.CENTER;
- icon.setLayoutParams(iconLp);
- icon.setImageResource(page.getIconRes());
- tile.addView(icon);
-
- if (page.isSupporter()) {
- // The Supporter badge: a small accent dot in the tile's corner. A dot rather than a
- // second label keeps the rail icon-only at every width, which is what lets nine
- // destinations fit the landscape height.
- View badge = new View(activity);
- int badgeSize = (int) (7 * density());
- FrameLayout.LayoutParams badgeLp =
- new FrameLayout.LayoutParams(badgeSize, badgeSize);
- badgeLp.gravity = Gravity.END | Gravity.TOP;
- badgeLp.topMargin = (int) (5 * density());
- badgeLp.rightMargin = (int) (5 * density());
- badge.setLayoutParams(badgeLp);
- android.graphics.drawable.GradientDrawable dot =
- new android.graphics.drawable.GradientDrawable();
- dot.setShape(android.graphics.drawable.GradientDrawable.OVAL);
- dot.setColor(accent);
- badge.setBackground(dot);
- tile.addView(badge);
- }
-
- tile.setOnClickListener(v -> onSelect.accept(page));
- tile.setOnLongClickListener(v -> {
- Toast.makeText(activity, page.getTitleRes(), Toast.LENGTH_SHORT).show();
- return true;
- });
-
- container.addView(tile);
- tiles.put(page, tile);
- icons.put(page, icon);
- }
- applySelection();
- }
-
- /** Paints the selection: the active tile gets an accent-tinted icon and a filled pill. */
- void setSelected(ModMenuPage page) {
- if (page == null || !tiles.containsKey(page)) return;
- selected = page;
- applySelection();
- }
-
- private void applySelection() {
- for (Map.Entry entry : icons.entrySet()) {
- boolean active = entry.getKey() == selected;
- entry.getValue().setColorFilter(active ? accent : UiTokens.TEXT_SECONDARY);
- FrameLayout tile = tiles.get(entry.getKey());
- if (tile != null) {
- tile.setBackground(active ? activeTile() : null);
- }
- }
- }
-
- private android.graphics.drawable.GradientDrawable activeTile() {
- android.graphics.drawable.GradientDrawable bg =
- new android.graphics.drawable.GradientDrawable();
- bg.setShape(android.graphics.drawable.GradientDrawable.RECTANGLE);
- bg.setCornerRadius(12 * density());
- bg.setColor(UiTokens.BG_SURFACE_HOVER);
- bg.setStroke(Math.max(1, (int) density()), withAlpha(accent, 0x55));
- return bg;
- }
-
- private float density() {
- return activity.getResources().getDisplayMetrics().density;
- }
-
- private static int withAlpha(int color, int alpha) {
- return (color & 0x00FFFFFF) | ((alpha & 0xFF) << 24);
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowContent.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowContent.java
deleted file mode 100644
index 16877e49..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowContent.java
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import java.util.Map;
-
-/**
- * Whether two versions of a module row draw the same thing.
- *
- * The RecyclerView diff decides, per row, whether to rebind it. That decision has to see
- * everything the row renders from, or a change goes unnoticed and the row stays stale on
- * screen. The three things that bit: a module the crash guard disabled (the user's toggle is
- * unchanged, so a toggle-only comparison says "same"), a module whose availability changed with the
- * build, and the favourite star.
- *
- *
Extracted from {@link ModMenuAdapter} so the rule is a pure function with a unit test rather
- * than a branch inside an Android adapter, where it could only be exercised on a device. The adapter
- * still owns the two lists and the diff; this owns the "same content?" answer.
- */
-public final class ModMenuRowContent {
-
- private ModMenuRowContent() {
- }
-
- /**
- * The state a row is drawn with, resolved from an explicit map and the availability/enabled
- * fallback.
- *
- * @param states the row-state map in force for the side being compared (the map the row was
- * bound with on the old side, the incoming map on the new side)
- * @param stableKey the module's stable key
- * @param available whether the module is interactive on this build
- * @param enabled whether the user has it on
- */
- public static ModMenuRowState resolve(Map states, String stableKey,
- boolean available, boolean enabled) {
- ModMenuRowState explicit = states != null ? states.get(stableKey) : null;
- return explicit != null ? explicit : ModMenuRowState.of(true, available, false, enabled);
- }
-
- /**
- * Whether an old and a new version of a module row render identically.
- *
- * Every field the card draws from is compared: the name, the caption, whether it has a
- * settings button, the compact-mode group label, the toggle, the resolved row state and the
- * favourite star. Missing any one of them is a row that does not refresh.
- */
- public static boolean sameContent(String oldName, String newName,
- String oldDescription, String newDescription,
- boolean oldHasConfig, boolean newHasConfig,
- boolean oldShowGroupLabel, boolean newShowGroupLabel,
- boolean oldEnabled, boolean newEnabled,
- ModMenuRowState oldState, ModMenuRowState newState,
- boolean oldFavorite, boolean newFavorite) {
- return java.util.Objects.equals(oldName, newName)
- && java.util.Objects.equals(oldDescription, newDescription)
- && oldHasConfig == newHasConfig
- && oldShowGroupLabel == newShowGroupLabel
- && oldEnabled == newEnabled
- && oldState == newState
- && oldFavorite == newFavorite;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowKind.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowKind.java
deleted file mode 100644
index c52af100..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowKind.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * The one rule that decides a row's control type (M2, M3).
- *
- *
The menu used to render every entry as a tap target, so a stateful module looked like
- * a one-shot button and a tap never showed whether it was on. The sweetloader API already
- * distinguishes the two — {@code ModuleBuilder(...).registerModule()} has an enable state,
- * {@code ButtonBuilder(...).registerButton()} fires an action — and this class is that distinction
- * expressed once, so the UI cannot drift from the API.
- *
- *
The rule: anything with an enable state gets a switch; anything that fires once gets an
- * action row and never a switch. Never both. A switch that instantly snaps back is the single most
- * confusing control in any mod menu.
- *
- *
Pure, so the rule is unit-tested without a device.
- */
-public enum ModMenuRowKind {
- /** A module: it has an on/off state and is drawn with a switch. */
- SWITCH,
- /** An action: it fires once and is drawn with a chevron, never a switch. */
- ACTION;
-
- /**
- * The kind for an entry.
- *
- * @param hasEnableState true for a registered module (it can be toggled on and off)
- */
- public static ModMenuRowKind of(boolean hasEnableState) {
- return hasEnableState ? SWITCH : ACTION;
- }
-
- /** True when the row shows a switch control. */
- public boolean showsSwitch() {
- return this == SWITCH;
- }
-
- /** True when the row shows a chevron and fires on tap. */
- public boolean showsChevron() {
- return this == ACTION;
- }
-
- /**
- * Whether a row may present both controls.
- *
- *
Always false, and it exists as a named rule rather than a comment so the invariant is
- * asserted rather than assumed: a row that both toggles a state and fires an action is exactly
- * the ambiguity this classification removes.
- */
- public static boolean mayCombine() {
- return false;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowState.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowState.java
deleted file mode 100644
index 5857b3f1..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowState.java
+++ /dev/null
@@ -1,61 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * Why a Mod Menu row is or is not usable (M10, and G8's UI form).
- *
- *
A row used to be either enabled or not, so a module that failed to register looked identical to
- * one the user had switched off — the switch was simply off and nothing said why. Every row now
- * carries one of these, and the row's appearance, its dot colour and its interactivity all derive
- * from it. Pure, so the mapping is unit-tested without a device.
- */
-public enum ModMenuRowState {
- /** Registered and healthy. The switch is interactive. */
- LOADED(true, false, false),
- /** The user turned it off. Still healthy. */
- DISABLED(true, false, false),
- /** Registration failed. Dimmed, switch disabled, red dot, tap shows the reason. */
- FAILED(false, true, true),
- /** No hook for this Bedrock build. Dimmed, amber dot, switch disabled. */
- UNSUPPORTED(false, true, true),
- /** Auto-disabled after repeated crashes (G8). Dimmed, red dot, offers a re-enable. */
- CRASHED(false, true, true);
-
- private final boolean interactive;
- private final boolean dimmed;
- private final boolean hasReason;
-
- ModMenuRowState(boolean interactive, boolean dimmed, boolean hasReason) {
- this.interactive = interactive;
- this.dimmed = dimmed;
- this.hasReason = hasReason;
- }
-
- /** Whether the row's switch may be flipped. */
- public boolean isInteractive() {
- return interactive;
- }
-
- /** Whether the row is drawn at reduced opacity. */
- public boolean isDimmed() {
- return dimmed;
- }
-
- /** Whether a tap on the row should open the reason sheet rather than toggle. */
- public boolean hasReason() {
- return hasReason;
- }
-
- /** True for a state that means the module cannot run at all right now. */
- public boolean isBroken() {
- return this == FAILED || this == UNSUPPORTED || this == CRASHED;
- }
-
- /** The state for a row, given what the registry and the user's toggle say. */
- public static ModMenuRowState of(boolean registered, boolean available, boolean crashed,
- boolean enabled) {
- if (!registered) return FAILED;
- if (crashed) return CRASHED;
- if (!available) return UNSUPPORTED;
- return enabled ? LOADED : DISABLED;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuScreen.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuScreen.java
deleted file mode 100644
index 99d04734..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuScreen.java
+++ /dev/null
@@ -1,155 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.app.Activity;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.FrameLayout;
-
-import androidx.compose.runtime.MutableState;
-import androidx.compose.ui.platform.ComposeView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.mods.inbuilt.UnifiedMod;
-import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
-import org.chimeramc.client.ui.compose.modmenu.ModMenuDestination;
-import org.chimeramc.client.ui.compose.modmenu.ModMenuHost;
-import org.chimeramc.client.ui.compose.modmenu.ModMenuState;
-import org.chimeramc.client.ui.compose.modmenu.ModMenuStateBuilder;
-import org.chimeramc.client.util.AppLog;
-
-import java.util.List;
-import java.util.Set;
-import java.util.function.UnaryOperator;
-
-/**
- * The Compose presentation of the Mod Menu, layered over the View menu's container.
- *
- *
Extracted from {@code ModMenuOverlay} so that class stays inside its size budget. The overlay
- * keeps the View tree, the persistence and the callbacks; this class owns the Compose view, its
- * state, and the mapping from Compose interactions back to the overlay's persistence. That split is
- * also what lets the Compose layer be dropped entirely on a device where it fails to initialise.
- *
- *
The Compose tree is added over the already-inflated View tree, so a failure here is caught and
- * logged and the working View menu stays visible rather than a blank window.
- *
- *
The package is {@code ...core.mods.inbuilt.overlay} (not the {@code ...launcher.core...} path
- * this file sits under) because that is what the sibling sources declare. A file placed by path with
- * the path-derived package would not be a package-mate of {@code ModMenuOverlay} and could not reach
- * its package-private members.
- */
-final class ModMenuScreen {
-
- /** Called when a module toggle arrives from the Compose UI, so the overlay can persist it. */
- interface ModuleToggle {
- void onToggle(String stableKey, boolean enabled);
- }
-
- private static final String TAG = "ModMenuScreen";
-
- private final Activity activity;
- private final View overlayView;
- private final List mods;
- private final Set favoriteKeys;
- private final ModuleToggle moduleToggle;
- private final Runnable onClose;
-
- private ComposeView composeView;
- private MutableState state;
- private boolean attached;
-
- ModMenuScreen(Activity activity,
- View overlayView,
- List mods,
- Set favoriteKeys,
- ModuleToggle moduleToggle,
- Runnable onClose) {
- this.activity = activity;
- this.overlayView = overlayView;
- this.mods = mods;
- this.favoriteKeys = favoriteKeys;
- this.moduleToggle = moduleToggle;
- this.onClose = onClose;
- }
-
- /** Builds the Compose menu and adds it over the View menu's container. Idempotent. */
- void attach() {
- if (attached || overlayView == null) return;
- attached = true;
- try {
- state = ModMenuHost.state(
- ModMenuStateBuilder.build(mods, favoriteKeys, ModMenuDestination.MODULES));
-
- composeView = new ComposeView(activity);
- composeView.setLayoutParams(new FrameLayout.LayoutParams(
- FrameLayout.LayoutParams.MATCH_PARENT,
- FrameLayout.LayoutParams.MATCH_PARENT));
-
- ModMenuHost.setContent(
- composeView,
- state,
- destination -> update(s -> s.withDestination(destination)),
- query -> update(s -> s.withSearch(query)),
- (stableKey, enabled) -> {
- moduleToggle.onToggle(stableKey, enabled);
- refresh();
- },
- stableKey -> {
- toggleFavorite(stableKey);
- refresh();
- },
- () -> update(s -> s.withFavoritesOnly(!s.getFavoritesOnly())),
- (key, enabled) -> {
- InbuiltModManager.getInstance(activity).setModConfigBool(key, enabled);
- refresh();
- },
- onClose::run);
-
- FrameLayout host = overlayView.findViewById(R.id.mod_menu_container);
- if (host != null) {
- host.addView(composeView);
- } else if (overlayView instanceof ViewGroup) {
- ((ViewGroup) overlayView).addView(composeView);
- }
- } catch (Throwable e) {
- // Compose failing must never take the menu down: the View menu is already inflated
- // underneath, so dropping the Compose layer leaves a working screen.
- AppLog.warnOnce("CaramelClient", "ModMenuScreen:attach",
- "The Compose Mod Menu could not start; using the View menu", e);
- composeView = null;
- state = null;
- attached = false;
- }
- }
-
- /** Rebuilds the rows from the current mod list, preserving the destination and the filters. */
- void refresh() {
- if (state == null) return;
- try {
- final ModMenuState current = state.getValue();
- final ModMenuState rebuilt =
- ModMenuStateBuilder.build(mods, favoriteKeys, current.getDestination());
- state.setValue(rebuilt
- .withSearch(current.getSearch())
- .withFavoritesOnly(current.getFavoritesOnly()));
- } catch (Throwable e) {
- AppLog.warnOnce("CaramelClient", "ModMenuScreen:refresh",
- "Could not rebuild the Compose menu rows", e);
- }
- }
-
- private void update(UnaryOperator change) {
- if (state == null) return;
- state.setValue(change.apply(state.getValue()));
- }
-
- private void toggleFavorite(String stableKey) {
- if (stableKey == null) return;
- final boolean nowFavorite = !favoriteKeys.contains(stableKey);
- if (nowFavorite) {
- favoriteKeys.add(stableKey);
- } else {
- favoriteKeys.remove(stableKey);
- }
- InbuiltModManager.getInstance(activity).setModFavorite(stableKey, nowFavorite);
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuSwitch.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuSwitch.java
deleted file mode 100644
index c84d76ee..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuSwitch.java
+++ /dev/null
@@ -1,244 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.graphics.RectF;
-
-/**
- * The Mod Menu's pill switch, as pure geometry and colour (M5).
- *
- * A 52x32dp track with a 24dp thumb inset 4dp, spring-animated on the thumb only. The state
- * machine is here rather than in the view so a JVM test pins the travel, the colours and the
- * disabled appearance without a device.
- *
- *
Deliberately no third "pending" visual state: a module either enables or it does not, and if
- * {@code enable()} fails the switch animates back and a toast names the reason. An intermediate
- * state is how a control starts lying about what it did.
- */
-public final class ModMenuSwitch {
-
- private ModMenuSwitch() {
- }
-
- /** The thumb's travel in px: the track minus the thumb and both insets. */
- public static float travel(float density) {
- return ModMenuMetrics.SWITCH_WIDTH_DP * density
- - ModMenuMetrics.SWITCH_THUMB_DP * density
- - 2f * ModMenuMetrics.SWITCH_THUMB_INSET_DP * density;
- }
-
- /**
- * The thumb's centre X inside the track, for animation progress 0 (off) .. 1 (on).
- *
- *
The progress is clamped so an over-shooting spring cannot push the thumb outside its track.
- */
- public static float thumbCenterX(float density, float progress) {
- float p = Math.max(0f, Math.min(1f, progress));
- float inset = ModMenuMetrics.SWITCH_THUMB_INSET_DP * density;
- float thumb = ModMenuMetrics.SWITCH_THUMB_DP * density;
- return inset + thumb / 2f + travel(density) * p;
- }
-
- /** The track fill for a state: the accent when on, the muted accent when off. */
- public static int trackColor(boolean on) {
- return on ? ModMenuPalette.ACCENT : ModMenuPalette.mutedTrack();
- }
-
- /**
- * The track fill for a row that cannot be driven.
- *
- *
A failed module gets {@code state.error} at 30% rather than the muted accent, so the row's
- * failure is visible on the control itself and not only on the badge beside it.
- */
- public static int failedTrackColor() {
- return ModMenuPalette.blend(ModMenuPalette.BG_SURFACE_3,
- ModMenuPalette.STATE_ERROR, 0.30f);
- }
-
- /** The thumb colour: off-white normally, tertiary grey when the row is not interactive. */
- public static int thumbColor(boolean interactive) {
- return interactive ? ModMenuPalette.TEXT_PRIMARY : ModMenuPalette.TEXT_TERTIARY;
- }
-
- /**
- * The spring parameters for a state change (M5).
- *
- *
Turning on is springier than turning off : an enable is a commitment and the
- * overshoot reads as confirmation, while a disable should feel like a plain retreat.
- */
- public static float dampingFor(boolean turningOn) {
- return turningOn ? 0.72f : 0.85f;
- }
-
- public static float stiffnessFor(boolean turningOn) {
- return turningOn ? 520f : 480f;
- }
-
- /**
- * Whether a state change should tick the vibrator (M5).
- *
- *
Only the state change haptics — never the press-down, which would make the control feel
- * noisy and fire twice per flip.
- */
- public static boolean hapticsOnStateChange() {
- return true;
- }
-
- /**
- * The control's visual width in dp for a toggle style (M6).
- *
- *
{@code PILL_LABEL} reserves room for the "On"/"Off" word beside the pill; the plain switch
- * and the checkbox do not. The touch target is unaffected — it is always 48dp.
- */
- public static int visualWidthDp(ModMenuSettings.ToggleStyle style) {
- if (style == ModMenuSettings.ToggleStyle.PILL_LABEL) {
- return ModMenuMetrics.SWITCH_WIDTH_DP + PILL_LABEL_WIDTH_DP;
- }
- if (style == ModMenuSettings.ToggleStyle.CHECKBOX) return CHECKBOX_SIZE_DP;
- return ModMenuMetrics.SWITCH_WIDTH_DP;
- }
-
- /** The width the state word beside the pill occupies (M6). */
- public static final int PILL_LABEL_WIDTH_DP = 34;
- /** The checkbox's edge, a square (M6). */
- public static final int CHECKBOX_SIZE_DP = 22;
-
- /**
- * The TalkBack announcement for a switch (M5).
- *
- *
The spec asks for "Module name, On, Switch". The view carries this as its content
- * description and the accessibility node supplies the switch role, so TalkBack composes
- * "FPS Counter, On, Switch". Built here rather than in the view so the wording is unit-tested;
- * a null or blank label degrades to the state alone rather than announcing ", On".
- */
- public static String accessibilityLabel(String name, boolean on) {
- String state = on ? "On" : "Off";
- if (name == null || name.trim().isEmpty()) return state;
- return name.trim() + ", " + state;
- }
-
- /**
- * Draws the switch.
- *
- * @param interactive false draws the disabled appearance (grey thumb, no accent)
- * @param failed true draws the error track, so a broken row is visible on the control
- */
- public static void draw(Canvas canvas, Paint paint, float left, float top, float density,
- boolean on, float progress, boolean interactive, boolean failed) {
- draw(canvas, paint, left, top, density, on, progress, interactive, failed,
- ModMenuSettings.ToggleStyle.SWITCH);
- }
-
- /**
- * Draws the switch in the player's chosen toggle style (M6).
- *
- *
All three styles are the same control with the same state and the same 48dp touch
- * target; only the visual differs. That is why the style lives here as a drawing branch rather
- * than as three separate views — three views is how the checkbox variant ends up with different
- * behaviour from the switch.
- */
- public static void draw(Canvas canvas, Paint paint, float left, float top, float density,
- boolean on, float progress, boolean interactive, boolean failed,
- ModMenuSettings.ToggleStyle style) {
- ModMenuSettings.ToggleStyle effective = style == null
- ? ModMenuSettings.ToggleStyle.SWITCH : style;
- if (effective == ModMenuSettings.ToggleStyle.CHECKBOX) {
- drawCheckbox(canvas, paint, left, top, density, on, interactive, failed);
- return;
- }
-
- float width = ModMenuMetrics.SWITCH_WIDTH_DP * density;
- float height = ModMenuMetrics.SWITCH_HEIGHT_DP * density;
- float radius = height / 2f;
- RectF track = new RectF(left, top, left + width, top + height);
-
- paint.reset();
- paint.setAntiAlias(true);
- paint.setStyle(Paint.Style.FILL);
- if (failed) {
- paint.setColor(failedTrackColor());
- } else if (!interactive) {
- paint.setColor(ModMenuPalette.BG_SURFACE_3);
- } else {
- paint.setColor(trackColor(on));
- }
- canvas.drawRoundRect(track, radius, radius, paint);
-
- float thumb = ModMenuMetrics.SWITCH_THUMB_DP * density;
- float cx = left + thumbCenterX(density, progress);
- float cy = top + height / 2f;
- paint.setColor(thumbColor(interactive));
- canvas.drawCircle(cx, cy, thumb / 2f, paint);
-
- // The thumb's own shadow, so it reads as a raised part rather than a painted dot.
- paint.setStyle(Paint.Style.STROKE);
- paint.setStrokeWidth(Math.max(1f, density));
- paint.setColor(Color.argb(0x40, 0, 0, 0));
- canvas.drawCircle(cx, cy, thumb / 2f, paint);
-
- if (effective == ModMenuSettings.ToggleStyle.PILL_LABEL) {
- // The state word beside the pill, so the control reads "On"/"Off" as text too.
- paint.setStyle(Paint.Style.FILL);
- paint.setTextSize(11f * density);
- paint.setColor(interactive ? ModMenuPalette.TEXT_SECONDARY
- : ModMenuPalette.TEXT_TERTIARY);
- paint.setTextAlign(Paint.Align.LEFT);
- float textX = left + width + 6f * density;
- float baseline = cy + 4f * density;
- canvas.drawText(on ? "On" : "Off", textX, baseline, paint);
- }
- }
-
- /**
- * Draws the checkbox variant of the toggle (M6).
- *
- *
A square with a check mark, for the users who ask for the plainest possible control. It
- * carries the same state as the pill: the tick appears exactly when the module is enabled.
- */
- private static void drawCheckbox(Canvas canvas, Paint paint, float left, float top,
- float density, boolean on, boolean interactive,
- boolean failed) {
- float size = CHECKBOX_SIZE_DP * density;
- RectF box = new RectF(left, top, left + size, top + size);
- float radius = 6f * density;
-
- paint.reset();
- paint.setAntiAlias(true);
- paint.setStyle(Paint.Style.FILL);
- if (failed) {
- paint.setColor(failedTrackColor());
- } else if (on && interactive) {
- paint.setColor(ModMenuPalette.ACCENT);
- } else {
- paint.setColor(interactive ? ModMenuPalette.mutedTrack()
- : ModMenuPalette.BG_SURFACE_3);
- }
- canvas.drawRoundRect(box, radius, radius, paint);
-
- // The border, so an unchecked box is visible against the panel.
- paint.setStyle(Paint.Style.STROKE);
- paint.setStrokeWidth(Math.max(1f, density));
- paint.setColor(on && interactive ? ModMenuPalette.ACCENT
- : ModMenuPalette.BORDER_HAIRLINE);
- canvas.drawRoundRect(box, radius, radius, paint);
-
- if (on) {
- // A two-segment tick, drawn as two stroked lines. Deliberately not a Path: this runs on
- // every frame of the menu, and building a Path per draw is an allocation on the hot path.
- paint.setStyle(Paint.Style.STROKE);
- paint.setStrokeWidth(2f * density);
- paint.setStrokeCap(Paint.Cap.ROUND);
- paint.setColor(interactive ? ModMenuPalette.TEXT_PRIMARY
- : ModMenuPalette.TEXT_TERTIARY);
- float x0 = left + size * 0.24f;
- float y0 = top + size * 0.52f;
- float x1 = left + size * 0.44f;
- float y1 = top + size * 0.72f;
- float x2 = left + size * 0.78f;
- float y2 = top + size * 0.30f;
- canvas.drawLine(x0, y0, x1, y1, paint);
- canvas.drawLine(x1, y1, x2, y2, paint);
- }
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuSwitchView.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuSwitchView.java
deleted file mode 100644
index 954486d6..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuSwitchView.java
+++ /dev/null
@@ -1,276 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import org.chimeramc.client.util.AppLog;
-
-import org.chimeramc.client.util.ApiLevels;
-
-import android.animation.ValueAnimator;
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.os.Build;
-import android.util.AttributeSet;
-import android.view.HapticFeedbackConstants;
-import android.view.View;
-import android.view.animation.LinearInterpolator;
-import android.view.animation.PathInterpolator;
-
-
-/**
- * The Mod Menu's pill switch (M2, M5).
- *
- *
A module has an enable state ; an action fires once. Rendering both as tap targets is the
- * component-type mismatch that made the menu feel wrong — a stateful switch that looks like a
- * one-shot button never shows whether it is on. This view is the switch half of that rule: it shows
- * the state, animates the state change, and refuses to be a button.
- *
- *
Geometry and colours come from the pure {@link ModMenuSwitch}; this class only animates and
- * paints. Both constructors exist so a layout can inflate it.
- */
-public class ModMenuSwitchView extends View {
-
- /** Called when the user flips the switch. */
- public interface OnCheckedChangeListener {
- void onCheckedChanged(boolean checked);
- }
-
- /** The on-state spring: springier, because an enable is a commitment. */
- private static final PathInterpolator SPRING_ON =
- new PathInterpolator(0.2f, 0f, 0.1f, 1.15f);
- /** The off-state spring: flatter, because a disable is a plain retreat. */
- private static final PathInterpolator SPRING_OFF =
- new PathInterpolator(0.3f, 0f, 0.2f, 1f);
-
- private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final float density;
-
- private boolean checked;
- private float progress;
- private boolean interactive = true;
- private boolean failed;
- private boolean hapticsEnabled = true;
- private boolean reducedMotion;
- private ModMenuSettings.ToggleStyle toggleStyle = ModMenuSettings.ToggleStyle.SWITCH;
- private String label;
- private OnCheckedChangeListener listener;
- private ValueAnimator animator;
-
- public ModMenuSwitchView(Context context) {
- this(context, null);
- }
-
- public ModMenuSwitchView(Context context, AttributeSet attrs) {
- super(context, attrs);
- density = getResources().getDisplayMetrics().density;
- setClickable(true);
- setFocusable(true);
- setDefaultFocusHighlightEnabled(false);
- // The visual is 52x32dp, which is narrower than the 48dp minimum in one axis; the view
- // reserves the full target so a thumb-sized press still lands.
- setMinimumWidth((int) (ModMenuMetrics.MIN_TOUCH_TARGET_DP * density));
- setMinimumHeight((int) (ModMenuMetrics.MIN_TOUCH_TARGET_DP * density));
- updateContentDescription();
- }
-
- public boolean isChecked() {
- return checked;
- }
-
- /** Sets the state without notifying the listener; {@code animate} false snaps. */
- public void setChecked(boolean value, boolean animate) {
- this.checked = value;
- if (!animate || !isAttachedToWindow()) {
- cancelAnimator();
- progress = value ? 1f : 0f;
- updateContentDescription();
- invalidate();
- return;
- }
- if (progress == (value ? 1f : 0f)) return;
- cancelAnimator();
- animator = ValueAnimator.ofFloat(progress, value ? 1f : 0f);
- animator.setDuration(ModMenuMotion.durationMs(reducedMotion));
- // Reduced motion is linear (a short fade, no travel); full motion is the on/off spring.
- animator.setInterpolator(reducedMotion
- ? new LinearInterpolator()
- : (value ? SPRING_ON : SPRING_OFF));
- animator.addUpdateListener(a -> {
- progress = (float) a.getAnimatedValue();
- invalidate();
- });
- animator.start();
- updateContentDescription();
- }
-
- /** A module that cannot be driven shows the disabled appearance and refuses the tap. */
- public void setInteractive(boolean value) {
- this.interactive = value;
- setClickable(value);
- invalidate();
- }
-
- /** A module that failed to register shows the error track (M10). */
- public void setFailed(boolean value) {
- this.failed = value;
- invalidate();
- }
-
- /** Disables the haptic tick for this instance (e.g. a settings preview). */
- public void setHapticsEnabled(boolean value) {
- this.hapticsEnabled = value;
- }
-
- /**
- * Switches to the reduced-motion scheme (M6, U11).
- *
- *
Reduced motion is a short linear transition rather than a spring: still a transition, so
- * the control does not pop, but with no overshoot. The OS setting wins over the in-menu choice,
- * which is why this is pushed in from the overlay rather than read here.
- */
- public void setReducedMotion(boolean value) {
- this.reducedMotion = value;
- }
-
- /**
- * Switches the control's visual to the player's chosen toggle style (M6).
- *
- *
Only the visual changes: the state, the touch target and the listener are identical for all
- * three styles, so a player who picks the checkbox still gets the same module state as one who
- * keeps the pill.
- */
- public void setToggleStyle(ModMenuSettings.ToggleStyle value) {
- ModMenuSettings.ToggleStyle next = value == null
- ? ModMenuSettings.ToggleStyle.SWITCH : value;
- if (this.toggleStyle == next) return;
- this.toggleStyle = next;
- requestLayout();
- invalidate();
- }
-
- /** The style currently drawn (M6). */
- public ModMenuSettings.ToggleStyle getToggleStyle() {
- return toggleStyle;
- }
-
- public void setOnCheckedChangeListener(OnCheckedChangeListener value) {
- this.listener = value;
- }
-
- @Override
- protected void onDraw(Canvas canvas) {
- super.onDraw(canvas);
- if (getWidth() <= 0 || getHeight() <= 0) return;
- // Centre the visual in whatever the layout gave us, so the invisible padding the touch
- // target needs does not shift the control. The visual's width follows the toggle style
- // (M6): a plain pill, a pill with a state word, or a square checkbox.
- float visualWidth = ModMenuSwitch.visualWidthDp(toggleStyle) * density;
- float visualHeight = (toggleStyle == ModMenuSettings.ToggleStyle.CHECKBOX
- ? ModMenuSwitch.CHECKBOX_SIZE_DP
- : ModMenuMetrics.SWITCH_HEIGHT_DP) * density;
- float left = (getWidth() - visualWidth) / 2f;
- float top = (getHeight() - visualHeight) / 2f;
- ModMenuSwitch.draw(canvas, paint, left, top, density, checked, progress, interactive,
- failed, toggleStyle);
- }
-
- @Override
- public boolean performClick() {
- if (!interactive) return super.performClick();
- boolean next = !checked;
- setChecked(next, true);
- if (hapticsEnabled && ModMenuSwitch.hapticsOnStateChange()) {
- performHapticFeedback(ApiLevels.atLeastO()
- ? HapticFeedbackConstants.CLOCK_TICK
- : HapticFeedbackConstants.VIRTUAL_KEY);
- }
- if (listener != null) listener.onCheckedChanged(next);
- return true;
- }
-
- @Override
- protected void onDetachedFromWindow() {
- cancelAnimator();
- super.onDetachedFromWindow();
- }
-
- private void cancelAnimator() {
- if (animator != null) {
- animator.cancel();
- animator = null;
- }
- }
-
- private void updateContentDescription() {
- // TalkBack announces "Module name, On, Switch": this view's content description carries the
- // module name (the row sets it) and the state, and onInitializeAccessibilityNodeInfo supplies
- // the switch role. A bare "On" loses the module it belongs to and does not announce the
- // control as a switch, so assistive tech cannot tell it is toggleable.
- setContentDescription(ModMenuSwitch.accessibilityLabel(label, checked));
- announceStateChanged();
- }
-
- /**
- * The module name this switch belongs to, announced before the state.
- *
- *
The row sets it on bind so a recycled switch does not announce the previous module's name.
- */
- public void setLabel(String value) {
- this.label = value;
- updateContentDescription();
- }
-
- /**
- * Re-emits the accessibility node after a state change.
- *
- *
An {@code invalidate()} repaints but does not re-announce, so TalkBack would keep reading
- * the previous state. The event is best-effort: an announcement must never be able to break the
- * control, so a failure here is swallowed.
- */
- private void announceStateChanged() {
- if (!isAttachedToWindow()) return;
- try {
- android.view.accessibility.AccessibilityEvent event =
- android.view.accessibility.AccessibilityEvent.obtain(
- android.view.accessibility.AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
- event.setContentChangeTypes(
- android.view.accessibility.AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION);
- event.setSource(this);
- sendAccessibilityEventUnchecked(event);
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "ModMenuSwitchView:240", "Ignored a failure at ModMenuSwitchView:240", ignored);
- }
- }
-
- @Override
- public void onInitializeAccessibilityNodeInfo(android.view.accessibility.AccessibilityNodeInfo info) {
- super.onInitializeAccessibilityNodeInfo(info);
- // The role and the state, so TalkBack announces "..., On, Switch" rather than an unnamed
- // clickable view. `setClassName` on the framework Switch is what makes the switch role.
- info.setClassName("android.widget.Switch");
- info.setCheckable(true);
- info.setChecked(checked);
- info.setEnabled(interactive);
- }
-
- @Override
- public boolean onKeyDown(int keyCode, android.view.KeyEvent event) {
- // Space activates a switch the way it activates a button; the base View already maps
- // Enter/DPAD_CENTER onto performClick, but not Space.
- if (keyCode == android.view.KeyEvent.KEYCODE_SPACE) {
- return performClick();
- }
- return super.onKeyDown(keyCode, event);
- }
-
- @Override
- protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
- // The touch target is always at least 48dp; the pill+label style is wider than that, so the
- // visual is never clipped by the target (M6).
- int desiredW = (int) (Math.max(ModMenuMetrics.MIN_TOUCH_TARGET_DP,
- ModMenuSwitch.visualWidthDp(toggleStyle)) * density);
- int desiredH = (int) (ModMenuMetrics.MIN_TOUCH_TARGET_DP * density);
- setMeasuredDimension(
- resolveSize(desiredW, widthMeasureSpec),
- resolveSize(desiredH, heightMeasureSpec));
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuTheme.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuTheme.java
index dd85fd1d..ccfd3995 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuTheme.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuTheme.java
@@ -243,8 +243,4 @@ float disabledElevation() {
return 0f;
}
- /** The row's disabled opacity, from the tested state rule (M10). */
- float rowAlpha(ModMenuRowState rowState) {
- return rowState != null && rowState.isDimmed() ? 0.6f : 1f;
- }
}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModStatsFormatter.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModStatsFormatter.java
index c01fb873..7b02325f 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModStatsFormatter.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModStatsFormatter.java
@@ -17,7 +17,18 @@ private ModStatsFormatter() {
/** FPS text, or the em dash when the native counter has not produced a value yet. */
public static String fps(int fps) {
- return fps <= 0 ? NO_READING : fps + " FPS";
+ return fps(fps, false);
+ }
+
+ /**
+ * FPS text, marked with {@code ~} when the value is not the game's exact rate.
+ *
+ *
A rate derived from an inferred frame boundary, or the launcher's own refresh cadence, is
+ * not the game's frame rate; showing it bare would pass one measurement off as another.
+ */
+ public static String fps(int fps, boolean approximate) {
+ if (fps <= 0) return NO_READING;
+ return approximate ? "~" + fps + " FPS" : fps + " FPS";
}
/** Ping text. There is no latency source wired yet, so an unknown reads as the em dash. */
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModuleFilter.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModuleFilter.java
deleted file mode 100644
index 1a5d21e7..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModuleFilter.java
+++ /dev/null
@@ -1,31 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * The section filters the Mod Menu offers, and that callers outside it may ask for.
- *
- *
Extracted from {@code ModMenuOverlay} as a top-level type so the menu's filter set is a named,
- * independently referenceable contract rather than a nested detail of a 3,500-line class. The radial
- * wheel's sectors and the menu's filters must name the same set — {@code RadialWheelRoutingTest} pins
- * that — and sharing one type is what makes that structural instead of a convention.
- *
- *
The package is {@code ...core.mods.inbuilt.overlay} (not the {@code ...launcher.core...} path
- * this file sits under) because that is the package the 157 sibling sources in this directory
- * declare. A file placed by path with the path-derived package is not a package-mate of them and
- * cannot see them.
- */
-public enum ModuleFilter {
- ALL,
- FAVORITES,
- ENABLED,
- INBUILT,
- EXTERNAL,
- PVP,
- // The category split the VIP window used to own. With one menu (U4) they are filters of this
- // menu, and their predicates come from the tested ModCategory rather than a second
- // implementation.
- VISUALS,
- MISC,
- // The HUD section. Its predicate is ModIds.isHudModule, so the radial wheel's HUD sector and
- // this filter name the same set and cannot drift apart.
- HUD
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/OverlayLayoutManagerDialog.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/OverlayLayoutManagerDialog.java
deleted file mode 100644
index f35371a9..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/OverlayLayoutManagerDialog.java
+++ /dev/null
@@ -1,280 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.app.Activity;
-import android.app.Dialog;
-import android.graphics.Color;
-import android.graphics.Typeface;
-import android.graphics.drawable.ColorDrawable;
-import android.text.InputType;
-import android.util.TypedValue;
-import android.view.Gravity;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.Window;
-import android.widget.EditText;
-import android.widget.FrameLayout;
-import android.widget.LinearLayout;
-import android.widget.ScrollView;
-import android.widget.TextView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.replay.ReplayStyle;
-import org.chimeramc.client.ui.animation.DynamicAnim;
-
-import java.util.List;
-import java.util.function.Consumer;
-
-/**
- * The overlay layout manager.
- *
- *
A layout is a named set of overlay positions, so a player can keep a PvP arrangement and a
- * survival one. {@link OverlayLayoutStore} owns the storage and {@link InbuiltOverlayManager} owns
- * the capture and the apply; this dialog is the screen that drives both, and it writes straight
- * through so the manager and the wheel cannot disagree about what exists.
- *
- *
Saving captures the positions on screen right now, which is what a player expects from "save
- * this layout": arrange the HUD, then name it. A layout is applied either here or from a wheel
- * entry whose {@code SWITCH_LAYOUT} target names it.
- */
-final class OverlayLayoutManagerDialog {
-
- private OverlayLayoutManagerDialog() {
- }
-
- static void show(Activity activity, ModMenuTheme theme) {
- if (activity == null) return;
- final OverlayLayoutStore store = new OverlayLayoutStore(activity);
- final Dialog dialog = new Dialog(activity);
- dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
-
- LinearLayout root = new LinearLayout(activity);
- root.setOrientation(LinearLayout.VERTICAL);
- root.setPadding(dp(activity, 16), dp(activity, 14), dp(activity, 16), dp(activity, 12));
- root.setBackground(ReplayStyle.roundedStroked(theme.panelSurface(),
- ModMenuPalette.BORDER_HAIRLINE, 18f, activity));
-
- TextView title = new TextView(activity);
- title.setText(R.string.overlay_layout_title);
- title.setTextColor(theme.textPrimary());
- title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f);
- title.setTypeface(null, Typeface.BOLD);
- root.addView(title);
-
- ScrollView scroll = new ScrollView(activity);
- LinearLayout list = new LinearLayout(activity);
- list.setOrientation(LinearLayout.VERTICAL);
- scroll.addView(list, new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
- LinearLayout.LayoutParams scrollParams = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, dp(activity, 300));
- scrollParams.topMargin = dp(activity, 10);
- root.addView(scroll, scrollParams);
-
- final Runnable rebuild = new Runnable() {
- @Override
- public void run() {
- list.removeAllViews();
- List layouts = store.load();
- String activeId = store.getActiveId();
- if (layouts.isEmpty()) {
- TextView empty = new TextView(activity);
- empty.setText(R.string.overlay_layout_empty);
- empty.setTextColor(theme.textTertiary());
- empty.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f);
- list.addView(empty);
- return;
- }
- for (OverlayLayout layout : layouts) {
- if (layout == null) continue;
- addLayoutRow(activity, theme, list, store, this, layout,
- layout.id.equals(activeId));
- }
- }
- };
- rebuild.run();
-
- LinearLayout buttons = new LinearLayout(activity);
- buttons.setOrientation(LinearLayout.HORIZONTAL);
- buttons.setGravity(Gravity.END);
- LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- buttonParams.topMargin = dp(activity, 12);
-
- TextView save = sheetButton(activity, theme, R.string.overlay_layout_save_current, false);
- save.setOnClickListener(v -> promptForName(activity, theme, R.string.overlay_layout_save_title,
- "", name -> {
- InbuiltOverlayManager manager = InbuiltOverlayManager.getInstance();
- if (manager == null) return;
- OverlayLayout captured = manager.captureOverlayLayout(name);
- store.save(OverlayLayoutStore.upsert(store.load(), captured));
- store.setActiveId(captured.id);
- rebuild.run();
- }));
- buttons.addView(save);
-
- TextView done = sheetButton(activity, theme, R.string.wheel_manager_done, true);
- done.setOnClickListener(v -> dialog.dismiss());
- buttons.addView(done);
- root.addView(buttons, buttonParams);
-
- dialog.setContentView(root);
- Window window = dialog.getWindow();
- if (window != null) {
- window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
- window.setLayout(ReplayStyle.dpInt(activity, 360f),
- ViewGroup.LayoutParams.WRAP_CONTENT);
- window.setGravity(Gravity.CENTER);
- window.setDimAmount(0.6f);
- }
- DynamicAnim.animateDialogShow(root);
- dialog.show();
- }
-
- private static void addLayoutRow(Activity activity, ModMenuTheme theme, LinearLayout parent,
- OverlayLayoutStore store, Runnable rebuild,
- OverlayLayout layout, boolean active) {
- LinearLayout row = new LinearLayout(activity);
- row.setOrientation(LinearLayout.HORIZONTAL);
- row.setGravity(Gravity.CENTER_VERTICAL);
- row.setPadding(dp(activity, 10), dp(activity, 8), dp(activity, 6), dp(activity, 8));
- row.setBackground(ReplayStyle.roundedStroked(
- active ? theme.accentFill(30) : theme.disabledCardColor(),
- ModMenuPalette.BORDER_HAIRLINE, 12f, activity));
- LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- rowParams.bottomMargin = dp(activity, 6);
-
- LinearLayout text = new LinearLayout(activity);
- text.setOrientation(LinearLayout.VERTICAL);
- LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
- 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f);
- TextView name = new TextView(activity);
- name.setText(layout.name);
- name.setTextColor(theme.textPrimary());
- name.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f);
- text.addView(name);
- TextView count = new TextView(activity);
- count.setText(activity.getString(R.string.overlay_layout_entry_count, layout.size()));
- count.setTextColor(active ? theme.accent() : theme.textTertiary());
- count.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f);
- text.addView(count);
- row.addView(text, textParams);
-
- TextView apply = sheetButton(activity, theme, R.string.overlay_layout_apply, false);
- apply.setOnClickListener(v -> {
- InbuiltOverlayManager manager = InbuiltOverlayManager.getInstance();
- if (manager != null) manager.switchOverlayLayout(layout.id);
- rebuild.run();
- });
- row.addView(apply);
-
- TextView rename = sheetButton(activity, theme, R.string.overlay_layout_rename, false);
- rename.setOnClickListener(v -> promptForName(activity, theme,
- R.string.overlay_layout_rename_title, layout.name, newName -> {
- OverlayLayout renamed = OverlayLayoutStore.findById(store.load(), layout.id);
- if (renamed == null) return;
- renamed.name = newName;
- store.save(OverlayLayoutStore.upsert(store.load(), renamed));
- rebuild.run();
- }));
- row.addView(rename);
-
- TextView delete = sheetButton(activity, theme, R.string.overlay_layout_delete, false);
- delete.setOnClickListener(v -> {
- List remaining = new java.util.ArrayList<>();
- for (OverlayLayout other : store.load()) {
- if (other != null && !other.id.equals(layout.id)) remaining.add(other);
- }
- store.save(remaining);
- if (layout.id.equals(store.getActiveId())) store.setActiveId("");
- rebuild.run();
- });
- row.addView(delete);
-
- parent.addView(row, rowParams);
- }
-
- private static void promptForName(Activity activity, ModMenuTheme theme, int titleRes,
- String initial, Consumer onName) {
- final Dialog dialog = new Dialog(activity);
- dialog.requestWindowFeature(Window.FEATURE_NO_TITLE);
- LinearLayout root = new LinearLayout(activity);
- root.setOrientation(LinearLayout.VERTICAL);
- root.setPadding(dp(activity, 16), dp(activity, 14), dp(activity, 16), dp(activity, 12));
- root.setBackground(ReplayStyle.roundedStroked(theme.panelSurface(),
- ModMenuPalette.BORDER_HAIRLINE, 16f, activity));
-
- TextView title = new TextView(activity);
- title.setText(titleRes);
- title.setTextColor(theme.textPrimary());
- title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f);
- title.setTypeface(null, Typeface.BOLD);
- root.addView(title);
-
- EditText input = new EditText(activity);
- input.setInputType(InputType.TYPE_CLASS_TEXT);
- input.setText(initial == null ? "" : initial);
- input.setHint(R.string.wheel_manager_name_hint);
- input.setTextColor(theme.textPrimary());
- input.setHintTextColor(theme.textTertiary());
- LinearLayout.LayoutParams inputParams = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- inputParams.topMargin = dp(activity, 10);
- root.addView(input, inputParams);
-
- LinearLayout buttons = new LinearLayout(activity);
- buttons.setOrientation(LinearLayout.HORIZONTAL);
- buttons.setGravity(Gravity.END);
- LinearLayout.LayoutParams buttonParams = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- buttonParams.topMargin = dp(activity, 12);
-
- TextView cancel = sheetButton(activity, theme, R.string.wheel_manager_cancel, false);
- cancel.setOnClickListener(v -> dialog.dismiss());
- buttons.addView(cancel);
-
- TextView save = sheetButton(activity, theme, R.string.wheel_manager_save, true);
- save.setOnClickListener(v -> {
- String name = input.getText() == null ? "" : input.getText().toString().trim();
- if (name.isEmpty()) return;
- dialog.dismiss();
- onName.accept(name);
- });
- buttons.addView(save);
- root.addView(buttons, buttonParams);
-
- dialog.setContentView(root);
- Window window = dialog.getWindow();
- if (window != null) {
- window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
- window.setLayout(ReplayStyle.dpInt(activity, 300f),
- ViewGroup.LayoutParams.WRAP_CONTENT);
- window.setGravity(Gravity.CENTER);
- window.setDimAmount(0.6f);
- }
- dialog.show();
- }
-
- private static TextView sheetButton(Activity activity, ModMenuTheme theme, int labelRes,
- boolean primary) {
- TextView button = new TextView(activity);
- button.setText(labelRes);
- button.setTextColor(primary ? theme.panelSurface() : theme.textPrimary());
- button.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12f);
- button.setTypeface(null, Typeface.BOLD);
- button.setBackground(ReplayStyle.rippled(
- ReplayStyle.pill(primary ? theme.accent() : theme.disabledCardColor(), activity),
- theme.accentFill(60), activity));
- button.setPadding(dp(activity, 10), dp(activity, 6), dp(activity, 10), dp(activity, 6));
- LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.setMarginStart(dp(activity, 6));
- button.setLayoutParams(params);
- DynamicAnim.applyPressScale(button);
- return button;
- }
-
- private static int dp(Activity activity, float value) {
- return ReplayStyle.dpInt(activity, value);
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/OverlayLayoutStore.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/OverlayLayoutStore.java
index d2d612f2..97152dd0 100644
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/OverlayLayoutStore.java
+++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/OverlayLayoutStore.java
@@ -13,9 +13,9 @@
/**
* Persists the overlay layouts and the active one.
*
- * Stored in SharedPreferences as JSON, the same shape {@link WheelProfileStore} uses, so there is
- * one persistence idiom for a user-authored list. The store never throws: a corrupt blob reads as
- * "no layouts", because a wheel with an unreadable layout list must still be usable.
+ *
Stored in SharedPreferences as JSON, one persistence idiom for a user-authored list. The store
+ * never throws: a corrupt blob reads as "no layouts", because an overlay with an unreadable layout
+ * list must still be usable.
*/
public final class OverlayLayoutStore {
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/PackChangerPanel.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/PackChangerPanel.java
deleted file mode 100644
index d4945d62..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/PackChangerPanel.java
+++ /dev/null
@@ -1,334 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.app.Activity;
-import android.graphics.drawable.GradientDrawable;
-import android.view.Gravity;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.FrameLayout;
-import android.widget.LinearLayout;
-import android.widget.ScrollView;
-import android.widget.TextView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.content.InGamePackChanger;
-import org.chimeramc.client.core.versions.GameVersion;
-import org.chimeramc.client.core.versions.VersionManager;
-import org.chimeramc.client.util.LauncherStorage;
-
-import java.io.File;
-import java.util.List;
-
-/**
- * The Packs section of the in-game Mod Menu: the instance's resource packs with an on/off switch
- * each, so the player can change what is applied without leaving the world.
- *
- *
It is only offered when the instance has the in-game pack changer enabled in Instance
- * Settings. The section is built in code because the pack list is data-driven and must be
- * re-read after every toggle. It writes the same {@code global_resource_packs.json} the game
- * reads, through {@link InGamePackChanger}.
- */
-public final class PackChangerPanel {
-
- private final Activity activity;
- private final boolean compact;
- private final LinearLayout root;
- private final LinearLayout listColumn;
- private TextView statusLine;
- /** Sizes and texture resolutions are scanned off the main thread and cached. */
- private final org.chimeramc.client.core.content.PackAssetSummaryCache assetCache =
- new org.chimeramc.client.core.content.PackAssetSummaryCache();
-
- public PackChangerPanel(Activity activity, boolean compact) {
- this.activity = activity;
- this.compact = compact;
-
- root = new LinearLayout(activity);
- root.setOrientation(LinearLayout.VERTICAL);
- root.setPadding(dp(compact ? 12 : 18), dp(compact ? 8 : 14),
- dp(compact ? 12 : 18), dp(compact ? 8 : 14));
-
- TextView title = new TextView(activity);
- title.setText(R.string.pack_changer_title);
- title.setTextSize(compact ? 13f : 15f);
- title.setTextColor(0xFFE6E9EC);
- title.setTypeface(title.getTypeface(), android.graphics.Typeface.BOLD);
- root.addView(title);
-
- statusLine = new TextView(activity);
- statusLine.setTextSize(compact ? 10f : 11f);
- statusLine.setTextColor(0xFF8F979F);
- statusLine.setPadding(0, dp(2), 0, dp(8));
- root.addView(statusLine);
-
- ScrollView scroller = new ScrollView(activity);
- scroller.setFillViewport(true);
- listColumn = new LinearLayout(activity);
- listColumn.setOrientation(LinearLayout.VERTICAL);
- scroller.addView(listColumn, new FrameLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
- root.addView(scroller, new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
-
- rebuild();
- }
-
- public View getView() {
- return root;
- }
-
- private void rebuild() {
- listColumn.removeAllViews();
-
- if (!isEnabledForInstance()) {
- statusLine.setText(R.string.pack_changer_disabled);
- return;
- }
- File gameDataDir = resolveListingDir();
- if (gameDataDir == null) {
- statusLine.setText(R.string.pack_changer_no_instance);
- return;
- }
-
- List dirs = resolveGameDataDirs();
- List packs = InGamePackChanger.listPacks(dirs);
- int activeCount = 0;
- for (InGamePackChanger.PackEntry pack : packs) {
- if (pack.active) activeCount++;
- }
- statusLine.setText(activity.getString(R.string.pack_changer_status,
- activeCount, packs.size()));
-
- if (packs.isEmpty()) {
- TextView empty = new TextView(activity);
- empty.setText(R.string.pack_changer_empty);
- empty.setTextSize(compact ? 11f : 12f);
- empty.setTextColor(0xFF8F979F);
- empty.setPadding(0, dp(12), 0, 0);
- listColumn.addView(empty);
- return;
- }
-
- // The applied order (last entry wins a conflict) is what the move buttons act on. Rows
- // are listed in that order so "move up" always means "higher priority", never "up the
- // alphabetical list".
- List order = InGamePackChanger.activeOrder(resolveListingDir());
- final List displayed = order.isEmpty() ? activeUuidsInListOrder(packs) : order;
- java.util.Map byUuid = new java.util.HashMap<>();
- for (InGamePackChanger.PackEntry pack : packs) {
- byUuid.put(org.chimeramc.client.core.content.ResourcePackOrder.normalise(pack.uuid), pack);
- }
- java.util.List ordered = new java.util.ArrayList<>();
- for (String uuid : displayed) {
- InGamePackChanger.PackEntry entry = byUuid.get(uuid);
- if (entry != null) ordered.add(entry);
- }
- for (InGamePackChanger.PackEntry pack : packs) {
- if (!displayed.contains(org.chimeramc.client.core.content.ResourcePackOrder
- .normalise(pack.uuid))) {
- ordered.add(pack);
- }
- }
- for (int i = 0; i < ordered.size(); i++) {
- InGamePackChanger.PackEntry pack = ordered.get(i);
- listColumn.addView(buildRow(pack, i, ordered));
- }
- }
-
- /** The active packs' uuids in the list's own order, used when there is no global file. */
- private List activeUuidsInListOrder(List packs) {
- List result = new java.util.ArrayList<>();
- for (InGamePackChanger.PackEntry pack : packs) {
- if (pack.active) {
- result.add(org.chimeramc.client.core.content.ResourcePackOrder.normalise(pack.uuid));
- }
- }
- return result;
- }
-
- private View buildRow(InGamePackChanger.PackEntry pack, int index,
- List ordered) {
- LinearLayout row = new LinearLayout(activity);
- row.setOrientation(LinearLayout.HORIZONTAL);
- row.setGravity(Gravity.CENTER_VERTICAL);
- row.setPadding(0, dp(10), 0, dp(10));
-
- LinearLayout labels = new LinearLayout(activity);
- labels.setOrientation(LinearLayout.VERTICAL);
- row.addView(labels, new LinearLayout.LayoutParams(0,
- ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
-
- TextView name = new TextView(activity);
- name.setText(pack.name);
- name.setTextSize(compact ? 12f : 13f);
- name.setTextColor(0xFFE6E9EC);
- labels.addView(name);
-
- TextView meta = new TextView(activity);
- meta.setTextSize(compact ? 10f : 11f);
- meta.setTextColor(pack.active ? 0xFF71ACEF : 0xFF8F979F);
- labels.addView(meta);
- bindPackMeta(meta, pack);
-
- // Priority controls. Only meaningful for an active pack: a pack the game has not applied
- // has no position to change.
- if (pack.active && ordered.size() > 1) {
- LinearLayout priority = new LinearLayout(activity);
- priority.setOrientation(LinearLayout.HORIZONTAL);
- TextView up = buildPriorityButton(R.string.pack_changer_move_up, index > 0);
- up.setOnClickListener(v -> movePriority(ordered, index, index - 1));
- TextView down = buildPriorityButton(R.string.pack_changer_move_down,
- index < ordered.size() - 1);
- down.setOnClickListener(v -> movePriority(ordered, index, index + 1));
- priority.addView(up);
- priority.addView(down);
- labels.addView(priority);
- }
-
- // A pill toggle rather than a SwitchMaterial: the overlay is inflated from a plain
- // FrameLayout and has no Material theme context here.
- TextView toggle = new TextView(activity);
- toggle.setPadding(dp(14), dp(6), dp(14), dp(6));
- toggle.setTextSize(compact ? 11f : 12f);
- toggle.setText(pack.active ? R.string.pack_changer_disable
- : R.string.pack_changer_enable);
- toggle.setTextColor(pack.active ? 0xFFE6E9EC : 0xFFA8B0B8);
- GradientDrawable background = new GradientDrawable();
- background.setCornerRadius(dp(16));
- background.setColor(pack.active ? 0x556236E8 : 0x3324282C);
- background.setStroke(dp(1), pack.active ? 0xFF0F5BE4 : 0x44FFFFFF);
- toggle.setBackground(background);
- toggle.setOnClickListener(v -> {
- // Apply to every candidate root the way the cape/installer does: the game picks its
- // storage from isolation + internal/external, and a write to only the wrong guess is
- // silently ignored. InGamePackChanger.apply then applies it live (in-place reload, or
- // a relaunch) and tells us which happened.
- InGamePackChanger.ApplyOutcome outcome = InGamePackChanger.apply(
- resolveGameDataDirs(), pack.uuid, pack.version, !pack.active);
- if (outcome == InGamePackChanger.ApplyOutcome.FAILED) {
- statusLine.setText(R.string.pack_changer_write_failed);
- return;
- }
- // A relaunch tears this overlay and the game down; there is nothing left to rebuild,
- // and rebuilding now would race the teardown.
- if (outcome == InGamePackChanger.ApplyOutcome.RESTARTING) {
- statusLine.setText(R.string.pack_changer_restarting);
- return;
- }
- rebuild();
- statusLine.setText(outcome == InGamePackChanger.ApplyOutcome.RELOADED
- ? R.string.pack_changer_reloaded
- : R.string.pack_changer_reload_on_next_load);
- });
- row.addView(toggle);
-
- return row;
- }
-
- /**
- * Fills a row's meta line with the pack's version/state plus its size and texture resolution.
- *
- * The size and resolution come from a background scan, so the line first shows what is known
- * synchronously and is repainted when the scan lands.
- */
- private void bindPackMeta(TextView meta, InGamePackChanger.PackEntry pack) {
- String state = pack.active ? activity.getString(R.string.pack_changer_on)
- : activity.getString(R.string.pack_changer_off);
- org.chimeramc.client.core.content.PackAssetSummary.Result summary =
- assetCache.peek(pack.directory);
- if (summary != null) {
- meta.setText(activity.getString(R.string.pack_changer_pack_size,
- formatBytes(summary.totalBytes), state));
- return;
- }
- meta.setText(activity.getString(R.string.pack_changer_pack_meta, pack.version, state));
- assetCache.get(pack.directory, (dir, result) -> {
- // The row may have been recycled to another pack by the time the scan lands; re-find
- // the view for this pack rather than trusting the captured one.
- if (result != null) rebuild();
- });
- }
-
- /** A small pill button for the priority controls, greyed when the move is not possible. */
- private TextView buildPriorityButton(int labelRes, boolean enabled) {
- TextView button = new TextView(activity);
- button.setText(labelRes);
- button.setTextSize(compact ? 10f : 11f);
- button.setPadding(dp(10), dp(4), dp(10), dp(4));
- button.setTextColor(enabled ? 0xFFE6E9EC : 0xFF5A6068);
- GradientDrawable background = new GradientDrawable();
- background.setCornerRadius(dp(12));
- background.setColor(0x3324282C);
- background.setStroke(dp(1), enabled ? 0x66FFFFFF : 0x22FFFFFF);
- button.setBackground(background);
- button.setEnabled(enabled);
- LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.setMargins(0, dp(6), dp(8), 0);
- button.setLayoutParams(params);
- return button;
- }
-
- /** Moves one pack's priority and rewrites the global list to match. */
- private void movePriority(List ordered, int from, int to) {
- List uuids = new java.util.ArrayList<>();
- for (InGamePackChanger.PackEntry entry : ordered) {
- uuids.add(org.chimeramc.client.core.content.ResourcePackOrder.normalise(entry.uuid));
- }
- List moved = org.chimeramc.client.core.content.ResourcePackOrder.move(uuids, from, to);
- File dir = resolveListingDir();
- if (dir == null || !InGamePackChanger.reorderActive(dir, moved)) {
- statusLine.setText(R.string.pack_changer_reorder_failed);
- return;
- }
- rebuild();
- }
-
- private String formatBytes(long bytes) {
- if (bytes < 1024) return bytes + " B";
- if (bytes < 1024L * 1024L) {
- return String.format(java.util.Locale.getDefault(), "%.1f KB", bytes / 1024.0);
- }
- if (bytes < 1024L * 1024L * 1024L) {
- return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0 * 1024.0));
- }
- return String.format(java.util.Locale.US, "%.1f GB",
- bytes / (1024.0 * 1024.0 * 1024.0));
- }
-
- /** True when the selected instance opted into the in-game pack changer. */
- private boolean isEnabledForInstance() {
- try {
- GameVersion version = VersionManager.get(activity).getSelectedVersion();
- return version != null && version.inGamePackChangerEnabled;
- } catch (Throwable t) {
- return false;
- }
- }
-
- /** The first candidate root, used only for listing (the game's packs live in one root). */
- private File resolveListingDir() {
- List dirs = resolveGameDataDirs();
- return dirs.isEmpty() ? null : dirs.get(0);
- }
-
- private List resolveGameDataDirs() {
- try {
- GameVersion version = VersionManager.get(activity).getSelectedVersion();
- if (version == null) return java.util.Collections.emptyList();
- return LauncherStorage.getCandidateGameDataDirs(
- activity, version.getStorageProfileId(), version.versionIsolation);
- } catch (Throwable t) {
- return java.util.Collections.emptyList();
- }
- }
-
- private int dp(float value) {
- return Math.round(value * activity.getResources().getDisplayMetrics().density);
- }
-
- /** Stops the background scan worker; call when the panel is removed. */
- public void dispose() {
- assetCache.shutdown();
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialHeadSkin.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialHeadSkin.java
deleted file mode 100644
index d4efd273..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialHeadSkin.java
+++ /dev/null
@@ -1,102 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.Rect;
-import android.graphics.RectF;
-
-import org.chimeramc.client.core.cosmetics.PlayerSkinProvider;
-import org.chimeramc.client.core.cosmetics.PreviewCharacterScene;
-import org.chimeramc.client.core.cosmetics.PreviewRaster;
-import org.chimeramc.client.core.cosmetics.PreviewScene;
-import org.chimeramc.client.core.cosmetics.SkinModel;
-
-/**
- * The radial wheel's hub head: the player's skin atlas, the 8x8 head crop and the scene that draws
- * it.
- *
- * Extracted from {@code RadialMenuView} so that class keeps headroom under the file-size budget,
- * and because the head is a self-contained unit: it owns the skin bitmap, the head pixel atlas, the
- * head-only {@link PreviewScene}, and the one crop rectangle all three depend on. The view keeps
- * only the drawing decision.
- *
- *
The scene holds only the head box, not a full body: the hub is a small disc, so a body
- * would be a tiny unreadable figure. Passing the {@code "head"} box id selects just the head and its
- * overlay from the same authored skin.
- *
- *
The package is {@code ...core.mods.inbuilt.overlay} to match the sibling sources in this
- * directory, not the {@code ...launcher.core...} path the file sits under.
- */
-final class RadialHeadSkin {
-
- /** The 8x8 head region of a 64x64 skin atlas. */
- private static final int HEAD_ORIGIN = 8;
- private static final int HEAD_SIZE = 8;
- private static final int ATLAS_CROP = 16;
-
- private final Rect srcHead = new Rect();
-
- private Bitmap skinHead;
- private PreviewScene headScene;
- private int[] headAtlas;
-
- /**
- * Re-resolves the player's skin and rebuilds the head.
- *
- *
Fail-closed: a missing skin, a decode failure or a throwing provider clears the head rather
- * than leaving a stale one, so the caller can fall back deliberately.
- */
- void resolve(android.content.Context context) {
- try {
- PlayerSkinProvider.SkinBitmap skin = PlayerSkinProvider.resolve(context);
- if (skin != null && skin.bitmap != null) {
- srcHead.set(HEAD_ORIGIN, HEAD_ORIGIN, ATLAS_CROP, ATLAS_CROP);
- skinHead = skin.bitmap;
- if (skin.bitmap.getWidth() == SkinModel.ATLAS_SIZE
- && skin.bitmap.getHeight() == SkinModel.ATLAS_SIZE) {
- headAtlas = new int[SkinModel.ATLAS_SIZE * SkinModel.ATLAS_SIZE];
- skin.bitmap.getPixels(headAtlas, 0, SkinModel.ATLAS_SIZE, 0, 0,
- SkinModel.ATLAS_SIZE, SkinModel.ATLAS_SIZE);
- headScene = new PreviewScene();
- PreviewCharacterScene.addCharacter(headScene, headAtlas, SkinModel.ATLAS_SIZE,
- 0f, 0f, "head");
- }
- }
- } catch (Throwable ignored) {
- skinHead = null;
- headScene = null;
- }
- }
-
- /** True when a flat head bitmap is available to blit. */
- boolean hasFlatHead() {
- return skinHead != null && !skinHead.isRecycled();
- }
-
- /** True when the 3D head scene was built and can be rendered. */
- boolean hasScene() {
- return headScene != null && headAtlas != null;
- }
-
- /** Draws the flat head crop into {@code dstHead}. */
- void drawFlat(Canvas canvas, RectF dstHead, Paint paint) {
- canvas.drawBitmap(skinHead, srcHead, dstHead, paint);
- }
-
- /**
- * Renders the 3D head into {@code raster} through the supplied camera.
- *
- * @return the fitted scene, so the caller can draw the raster it just filled
- */
- PreviewScene.Fit render(SkinModel.Camera cam, PreviewRaster raster, int size) {
- PreviewScene.Fit fit = headScene.fit(cam, size, size, 0.06f);
- headScene.render(raster, cam, fit);
- return fit;
- }
-
- /** The scene, for callers that need to fit or inspect it. Null when none was built. */
- PreviewScene scene() {
- return headScene;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialHeadTurn.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialHeadTurn.java
deleted file mode 100644
index 83448278..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialHeadTurn.java
+++ /dev/null
@@ -1,85 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * The radial wheel hub head's look rule, as pure arithmetic.
- *
- *
The hub shows the player's own head and it must stay still until the player moves something —
- * and when they do, the head should look at what they are pointing at . The old rule spun
- * the head like a turntable from stick velocity and never used the cursor's position, so it kept
- * turning after the cursor stopped. This rule is a clamped look-at: yaw and pitch are derived from
- * the cursor's offset from the hub's centre (about ±35° horizontally and ±25° vertically) and then
- * smoothed, so the head settles exactly on the cursor and works identically for touch and stick.
- *
- *
Pure so it is unit-tested without a device: "a centred cursor faces forward", "a cursor to the
- * right turns the head right", "a cursor below tilts the head down", "the angles clamp at the
- * extremes" and "motion is smoothed toward the target" are all properties a screenshot cannot check.
- */
-public final class RadialHeadTurn {
-
- /** Degrees of yaw at the horizontal edge of the hub (cursor hard right/left). */
- public static final float MAX_YAW_DEG = 35f;
- /** Degrees of pitch at the vertical edge of the hub (cursor hard up/down). */
- public static final float MAX_PITCH_DEG = 25f;
- /** How fast the head settles on the cursor: fraction of the remaining gap closed per second. */
- public static final float SMOOTHING_PER_SECOND = 8f;
-
- /** How far the look may fall behind a fast cursor sweep in one frame (smoothing bound). */
- private static final float MAX_FRAME_STEP_DEG = 18f;
-
- private RadialHeadTurn() {
- }
-
- /**
- * The target head look for a cursor at {@code cursorDx},{@code cursorDy} relative to the hub
- * centre, in screen pixels. Both axes clamp so the head cannot spin past its sockets.
- *
- * @param maxHorizontal the horizontal radius of the hub, in pixels (clamps yaw)
- * @param maxVertical the vertical radius of the hub, in pixels (clamps pitch)
- * @return {@code {yawDeg, pitchDeg}}, where yaw is positive looking right (screen +X) and pitch
- * is positive looking down (screen +Y, which grows downward)
- */
- public static float[] targetLook(float cursorDx, float cursorDy,
- float maxHorizontal, float maxVertical) {
- float hx = Math.max(1f, maxHorizontal);
- float hy = Math.max(1f, maxVertical);
- float yaw = clamp(cursorDx / hx, -1f, 1f) * MAX_YAW_DEG;
- // Screen Y grows down, so a cursor below the centre (positive dy) is a downward look.
- float pitch = clamp(cursorDy / hy, -1f, 1f) * MAX_PITCH_DEG;
- return new float[] {yaw, pitch};
- }
-
- /**
- * Steps the current head look toward the target by one frame.
- *
- * @param currentYaw the current yaw, degrees
- * @param currentPitch the current pitch, degrees
- * @param targetYaw the target yaw, degrees
- * @param targetPitch the target pitch, degrees
- * @param dtSeconds elapsed time; a non-positive or implausibly large value falls back to
- * one 60Hz frame so a long stall cannot teleport the head
- * @return {@code {yawDeg, pitchDeg}} eased toward the target
- */
- public static float[] stepTowards(float currentYaw, float currentPitch,
- float targetYaw, float targetPitch, float dtSeconds) {
- float dt = dtSeconds <= 0f || dtSeconds > 0.1f ? 1f / 60f : dtSeconds;
- float t = (float) (1.0 - Math.exp(-SMOOTHING_PER_SECOND * dt));
- float yaw = currentYaw + (targetYaw - currentYaw) * t;
- float pitch = currentPitch + (targetPitch - currentPitch) * t;
- // Bound a single frame so a teleported cursor (e.g. the view being laid out late) does not
- // fling the head around; the exponential already bounds it, this is a hard safety cap.
- yaw = clampStep(currentYaw, yaw);
- pitch = clampStep(currentPitch, pitch);
- return new float[] {yaw, pitch};
- }
-
- private static float clamp(float v, float lo, float hi) {
- return v < lo ? lo : Math.min(v, hi);
- }
-
- private static float clampStep(float from, float to) {
- float delta = to - from;
- if (delta > MAX_FRAME_STEP_DEG) return from + MAX_FRAME_STEP_DEG;
- if (delta < -MAX_FRAME_STEP_DEG) return from - MAX_FRAME_STEP_DEG;
- return to;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialMenuOverlay.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialMenuOverlay.java
deleted file mode 100644
index 6fedfe5d..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialMenuOverlay.java
+++ /dev/null
@@ -1,589 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import org.chimeramc.client.util.AppLog;
-
-import org.chimeramc.client.util.ApiLevels;
-
-import android.app.Activity;
-import android.content.Context;
-import android.graphics.PixelFormat;
-import android.view.Gravity;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.WindowManager;
-import android.widget.FrameLayout;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.mods.inbuilt.manager.InbuiltModManager;
-import org.chimeramc.client.core.mods.inbuilt.overlay.RadialWheelLayout.Sector;
-import org.chimeramc.client.core.minecraft.FpsOptimizationService;
-import org.chimeramc.client.settings.LowLatencyNetworkManager;
-import org.chimeramc.client.settings.ThermalGovernor;
-import org.chimeramc.client.util.PersonalizationManager;
-
-/**
- * The radial mod wheel as an in-game overlay (section 1 of the update spec).
- *
- *
It is a second screen beside the one Mod Menu: it is
- * opened from the same in-game bind and points at the same destinations. The four sectors route to
- * the module list, the cosmetics wardrobe, the settings and the HUD editor; the floating chips
- * toggle individual modules without opening a menu at all.
- *
- *
This is original work: the layout, colours and icons are the app's own (the violet/magenta
- * brand), built from {@link RadialMenuView} and the pure {@link RadialWheelLayout}. It deliberately
- * copies no other client's art, name or text.
- */
-public class RadialMenuOverlay {
-
- private final Activity activity;
- private WindowManager windowManager;
- private WindowManager.LayoutParams wmParams;
- private View overlayView;
- private RadialMenuView menuView;
- private boolean isShowing;
- /** The backdrop capture, so the wheel's glass can blur what is behind it (R3/R12). */
- private WheelBackdropCapture backdropCapture;
-
- /**
- * How the wheel reaches its destinations; supplied by whoever owns the other screens.
- *
- *
Each method names the destination, not the sector, so the wheel's sectors and the profile
- * entries share one routing table. The MODS and HUD sectors open the one Mod Menu on the
- * matching section (a filter over the module list, never a second list); CLOSET asks which of
- * the two customization surfaces to open; SETTINGS opens the Mod Menu's settings page.
- */
- public interface Destinations {
- /** The module list (the MODS sector). */
- void openMods();
- /** The HUD section of the module list (the HUD sector). */
- void openHud();
- /** Ask which customization surface to open (the CLOSET sector). */
- void openCustomize();
- /** The Mod Menu's settings page (the SETTINGS sector). */
- void openSettings();
- }
-
- private Destinations destinations;
-
- public RadialMenuOverlay(Activity activity) {
- this.activity = activity;
- this.windowManager = (WindowManager) activity.getSystemService(Context.WINDOW_SERVICE);
- }
-
- /**
- * Shows the wheel over a launcher screen (the Launch tab's quick dock).
- *
- *
The in-game wheel routes to the Mod Menu; over the launcher there is no game session, so
- * the destinations are the launcher's own screens. The wheel itself is the same overlay, so a
- * fix to the wheel geometry or the cursor reaches both homes.
- */
- public static void showForActivity(Activity activity) {
- RadialMenuOverlay overlay = new RadialMenuOverlay(activity);
- // One Mod Menu, reached through the same overlay the in-game wheel opens. The wheel is a
- // second screen beside the Mod Menu, so a sector must land on that menu's own filtered list
- // rather than a second, hand-built list (the Mods/ModsFullscreen screen, which lists
- // installed packages, not modules).
- final ModMenuOverlay[] menuRef = new ModMenuOverlay[1];
- overlay.setDestinations(new Destinations() {
- private ModMenuOverlay menu() {
- if (menuRef[0] == null) menuRef[0] = new ModMenuOverlay(activity);
- return menuRef[0];
- }
-
- @Override
- public void openMods() {
- menu().showModulesFiltered(ModuleFilter.ALL);
- }
-
- @Override
- public void openHud() {
- // The HUD section is a filter over the one module list, so it lands on the same
- // rows the HUD chip shows rather than silently opening the mods list.
- menu().showModulesFiltered(ModuleFilter.HUD);
- }
-
- @Override
- public void openCustomize() {
- // The same chooser the in-game wheel shows, so the two homes behave identically:
- // each choice opens the Mod Menu on the tab it names (settings / cosmetics).
- new CustomizeChooser(activity,
- () -> menu().showPage(ModMenuPage.SETTINGS),
- () -> menu().showPage(ModMenuPage.COSMETICS)).show();
- }
-
- @Override
- public void openSettings() {
- menu().showPage(ModMenuPage.SETTINGS);
- }
- });
- overlay.show();
- }
-
- public void setDestinations(Destinations destinations) {
- this.destinations = destinations;
- }
-
- public boolean isShowing() {
- return isShowing;
- }
-
- /** Opens or closes from the in-game bind. */
- public void toggle() {
- if (isShowing) {
- hide();
- } else {
- show();
- }
- }
-
- /** Whether the currently open wheel was opened by a hold gesture (see {@link WheelHoldPolicy}). */
- private boolean openedByHold;
-
- /**
- * Handles the bind's press under the hold-to-open rule.
- *
- * @return true when the press was consumed (the wheel opened, closed, or was already handled)
- */
- public boolean onBindPress(boolean holdToOpen) {
- WheelHoldPolicy.OnPress action =
- WheelHoldPolicy.onPress(holdToOpen, isShowing, openedByHold);
- switch (action) {
- case OPEN_HOLD:
- openedByHold = true;
- show();
- return true;
- case OPEN_TOGGLE:
- openedByHold = false;
- show();
- return true;
- case CLOSE:
- hide();
- return true;
- default:
- return false;
- }
- }
-
- /**
- * Handles the bind's release under the hold-to-open rule: commits the highlighted selection, or
- * dismisses when nothing is highlighted.
- *
- * @return true when the release was consumed
- */
- public boolean onBindRelease(boolean holdToOpen) {
- boolean hasSelection = menuView != null && menuView.hasSelection();
- WheelHoldPolicy.OnRelease action =
- WheelHoldPolicy.onRelease(holdToOpen, isShowing, openedByHold, hasSelection);
- openedByHold = false;
- switch (action) {
- case CONFIRM:
- if (menuView != null) menuView.controllerSelect();
- hide();
- return true;
- case DISMISS:
- hide();
- return true;
- default:
- return false;
- }
- }
-
- public void show() {
- if (isShowing || activity.isFinishing() || activity.isDestroyed()) return;
- try {
- showInternal();
- } catch (Throwable e) {
- detachQuietly();
- }
- }
-
- private void showInternal() {
- overlayView = LayoutInflater.from(activity).inflate(R.layout.overlay_radial_menu, null);
- menuView = overlayView.findViewById(R.id.radial_menu_view);
- menuView.setListener(new RadialMenuView.Listener() {
- @Override
- public void onSector(Sector sector) {
- hide();
- dispatch(sector);
- }
-
- @Override
- public void onDismiss() {
- hide();
- }
- });
- // Profile mode: the wheel draws the player's own profiles and routes each entry. It is
- // switched on here so the fixed four-destination wheel above stays the fallback and the
- // profile feature cannot regress it.
- try {
- menuView.setProfiles(activity);
- menuView.setEntryListener(new RadialMenuView.EntryListener() {
- @Override
- public void onEntry(WheelEntry entry) {
- if (entry == null) return;
- if (entry.action == WheelEntry.Action.SWITCH_LAYOUT) {
- InbuiltOverlayManager overlayManager = InbuiltOverlayManager.getInstance();
- if (overlayManager != null) {
- overlayManager.switchOverlayLayout(entry.target);
- }
- menuView.invalidate();
- return;
- }
- if (entry.action == WheelEntry.Action.TOGGLE_MODULE) {
- InbuiltModManager manager = InbuiltModManager.getInstance(activity);
- boolean next = !manager.resolveInbuiltModEnabled(entry.target, false);
- manager.setInbuiltModEnabled(entry.target, next);
- InbuiltOverlayManager overlayManager = InbuiltOverlayManager.getInstance();
- if (overlayManager != null) {
- overlayManager.handleModToggle(entry.target, next);
- }
- menuView.invalidate();
- return;
- }
- hide();
- dispatchProfileEntry(entry);
- }
-
- @Override
- public void onBack() {
- hide();
- }
- });
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "RadialMenuOverlay:257", "Ignored a failure at RadialMenuOverlay:257", ignored);
- }
- menuView.setChipToggle(new RadialMenuView.ChipToggle() {
- @Override
- public void onChipToggled(String moduleId) {
- InbuiltModManager manager = InbuiltModManager.getInstance(activity);
- boolean next = !manager.resolveInbuiltModEnabled(moduleId, false);
- manager.setInbuiltModEnabled(moduleId, next);
- InbuiltOverlayManager overlayManager = InbuiltOverlayManager.getInstance();
- if (overlayManager != null) {
- overlayManager.handleModToggle(moduleId, next);
- }
- menuView.invalidate();
- }
-
- @Override
- public boolean isChipOn(String moduleId) {
- return InbuiltModManager.getInstance(activity)
- .resolveInbuiltModEnabled(moduleId, false);
- }
- });
-
- wmParams = new WindowManager.LayoutParams(
- WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.TYPE_APPLICATION_PANEL,
- // FLAG_NOT_FOCUSABLE is load-bearing, not cosmetic. The wheel must never take window
- // focus: a focusable overlay becomes the key/event target, so the Activity stops
- // receiving the pad's motion events and the controller cursor has nothing to move
- // on. The other overlays already carry this flag; the wheel did not, which is why
- // the cursor appeared but never moved. The wheel drives itself from the events the
- // Activity forwards to handleControllerKey/Motion, so it needs no focus of its own.
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
- | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
- | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
- | WindowManager.LayoutParams.FLAG_FULLSCREEN,
- PixelFormat.TRANSLUCENT);
- if (ApiLevels.atLeastP()) {
- wmParams.layoutInDisplayCutoutMode =
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
- }
- wmParams.gravity = Gravity.CENTER;
- wmParams.token = activity.getWindow().getDecorView().getWindowToken();
- windowManager.addView(overlayView, wmParams);
- isShowing = true;
- // The glass material: the wheel asks the host for the device state (R2/R8/R10/R12) and
- // captures the window behind it for the blurred backdrop (R3). Both are fail-open: with no
- // capture the material resolves to its solid tier and the wheel is still fully usable.
- menuView.setMaterialEnvProvider(new RadialMenuView.MaterialEnvProvider() {
- @Override
- public RadialWheelMaterial.Env env() {
- return buildMaterialEnv(activity);
- }
- });
- // R3: the backdrop is shown on its own layer behind the wheel and blurred there. The layer
- // is what the RenderEffect is applied to on API 31+, because a RenderEffect on the wheel
- // itself would blur the wheel's sectors and labels.
- View backdrop = overlayView.findViewById(R.id.radial_backdrop_layer);
- if (backdrop instanceof android.widget.ImageView) {
- menuView.setBackdropLayer((android.widget.ImageView) backdrop);
- }
- backdropCapture = new WheelBackdropCapture();
- startBackdropRefreshLoop();
- // A pad-opened wheel enters controller-cursor mode: the cursor appears at the hub and the
- // stick drives it. Touch still works and hides the cursor on the first tap.
- menuView.post(() -> menuView.enableControllerCursor());
- }
-
- /**
- * Hands one controller key to the open wheel.
- *
- * @return true when the wheel consumed the press (so the game must not also act on it)
- */
- public boolean handleControllerKey(int keyCode, boolean down) {
- if (!isShowing || menuView == null || !down) return false;
- switch (keyCode) {
- case android.view.KeyEvent.KEYCODE_BUTTON_A:
- case android.view.KeyEvent.KEYCODE_DPAD_CENTER:
- case android.view.KeyEvent.KEYCODE_ENTER:
- return menuView.controllerSelect();
- case android.view.KeyEvent.KEYCODE_BUTTON_B:
- case android.view.KeyEvent.KEYCODE_BACK:
- return menuView.controllerBack();
- case android.view.KeyEvent.KEYCODE_BUTTON_X:
- return menuView.controllerPinHoveredChip();
- case android.view.KeyEvent.KEYCODE_DPAD_LEFT:
- menuView.focusNextSector(-1);
- return true;
- case android.view.KeyEvent.KEYCODE_DPAD_RIGHT:
- menuView.focusNextSector(1);
- return true;
- case android.view.KeyEvent.KEYCODE_BUTTON_R1:
- menuView.cycleChip(1);
- return true;
- case android.view.KeyEvent.KEYCODE_BUTTON_L1:
- menuView.cycleChip(-1);
- return true;
- default:
- return false;
- }
- }
-
- /**
- * Hands one analogue stick frame to the open wheel.
- *
- * @return true when the wheel consumed it (the game must not also move the camera)
- */
- public boolean handleControllerMotion(android.view.MotionEvent event) {
- if (!isShowing || menuView == null) return false;
- // Resolve the right stick by trying each axis convention and taking the first that carries a
- // deflection. Reading only AXIS_Z/AXIS_RZ meant a pad that reports its right stick on
- // AXIS_RX/AXIS_RY (or AXIS_RZ/AXIS_RX) delivered an all-zero vector, so the cursor never
- // moved. GamepadAxes owns that rule and is unit-tested.
- GamepadAxes.Stick stick = GamepadAxes.read(event);
- float lx = event.getAxisValue(android.view.MotionEvent.AXIS_X);
- float ly = event.getAxisValue(android.view.MotionEvent.AXIS_Y);
- boolean precision = Math.hypot(lx, ly) > 0.5f;
- boolean fast = event.getButtonState() != 0;
- long now = android.os.SystemClock.uptimeMillis();
- float dt = lastStickMs == 0L ? 1f / 60f : Math.min(0.1f, (now - lastStickMs) / 1000f);
- lastStickMs = now;
- // The centre dead zone is a user setting: a worn stick needs a wider one than a
- // new pad, and the value is clamped on read so a bad stored value cannot silence the cursor.
- float deadZone = InbuiltModManager.getInstance(activity).getWheelCenterDeadZone();
- float curve = CursorController.DEFAULT_CURVE_EXPONENT;
- lastStickSource = stick.source;
- return menuView.handleControllerStick(stick.x, stick.y, dt, precision, fast, deadZone, curve);
- }
-
- /** The axis convention the last stick frame used; reported by the diagnostics. */
- public String lastControllerStickSource() {
- return lastStickSource;
- }
-
- private String lastStickSource = "";
-
- private long lastStickMs;
-
- private void dispatch(Sector sector) {
- if (destinations == null) return;
- switch (sector) {
- case MODS:
- destinations.openMods();
- break;
- case CLOSET:
- destinations.openCustomize();
- break;
- case SETTINGS:
- destinations.openSettings();
- break;
- case HUD:
- destinations.openHud();
- break;
- default:
- break;
- }
- }
-
- /**
- * Routes a profile entry that is not a module toggle.
- *
- *
It maps onto the same {@link Destinations} the fixed wheel uses, so a profile entry and a
- * sector reach a destination through one path rather than two. A {@code TOGGLE_MODULE} entry
- * never arrives here (it is handled without closing the wheel); a {@code OPEN_SUBWHEEL} entry is
- * opened inside the view and never arrives here either.
- */
- private void dispatchProfileEntry(WheelEntry entry) {
- if (destinations == null || entry == null) return;
- switch (entry.action) {
- case OPEN_MODS:
- destinations.openMods();
- break;
- case OPEN_CLOSET:
- destinations.openCustomize();
- break;
- case OPEN_SETTINGS:
- destinations.openSettings();
- break;
- case OPEN_HUD:
- destinations.openHud();
- break;
- default:
- break;
- }
- }
-
- /**
- * Builds the environment the wheel's glass resolves against (R2, R8, R10, R12).
- *
- *
Reads the same signals the FPS optimizer coordinates — the thermal reading, battery saver
- * and the live-session flag — so the wheel's blur budget cannot disagree with the launcher's own
- * decision to step back while a game runs. The accessibility flags come from the personalization
- * manager, whose "reduce motion" switch already folds in the OS setting.
- */
- private static RadialWheelMaterial.Env buildMaterialEnv(Activity activity) {
- RadialWheelMaterial.Env env = new RadialWheelMaterial.Env();
- env.apiLevel(ApiLevels.sdkInt());
- try {
- PersonalizationManager personalization = new PersonalizationManager(activity);
- env.reduceMotion(personalization.isReduceMotion());
- // The app has no separate "reduce transparency" toggle, so the one accessibility switch
- // that means "calm the UI down" drives the glass too; the OS setting already feeds it.
- env.reduceTransparency(personalization.isReduceMotion());
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "RadialMenuOverlay:461", "Ignored a failure at RadialMenuOverlay:461", ignored);
- }
- try {
- env.sessionActive(LowLatencyNetworkManager.isGameSessionActive());
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "RadialMenuOverlay:466", "Ignored a failure at RadialMenuOverlay:466", ignored);
- }
- try {
- env.thermal(ThermalGovernor.severity(), isBatterySaver(activity), false);
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "RadialMenuOverlay:471", "Ignored a failure at RadialMenuOverlay:471", ignored);
- }
- try {
- // R8: the wheel asks the FPS Optimization coordinator rather than judging the device a
- // second time, so the blur budget cannot disagree with the launcher's own decision.
- env.budget(FpsOptimizationService.wheelBlurBudget());
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "RadialMenuOverlay:478", "Ignored a failure at RadialMenuOverlay:478", ignored);
- }
- return env;
- }
-
- private static boolean isBatterySaver(Activity activity) {
- try {
- android.os.PowerManager pm =
- (android.os.PowerManager) activity.getSystemService(Activity.POWER_SERVICE);
- return pm != null && pm.isPowerSaveMode();
- } catch (Throwable ignored) {
- return false;
- }
- }
-
- /**
- * Runs the backdrop capture on the capped refresh loop while the wheel is open (R3).
- *
- *
The wheel sits over a live game, so the backdrop has to be re-captured or it freezes; the
- * schedule is what keeps that at a few refreshes per second instead of every frame. The loop
- * stops the moment the wheel hides, so nothing keeps capturing after the game is the only thing
- * on screen.
- */
- private void startBackdropRefreshLoop() {
- mainHandler.removeCallbacks(backdropRefreshRunnable);
- mainHandler.post(backdropRefreshRunnable);
- }
-
- private final Runnable backdropRefreshRunnable = new Runnable() {
- @Override
- public void run() {
- if (!isShowing) return;
- requestBackdropCapture();
- RadialMenuView view = menuView;
- int fps = view == null || view.material() == null
- ? RadialWheelMaterial.BLUR_FPS_DEFAULT : view.material().blurFps;
- long interval = BackdropRefreshSchedule.intervalMs(fps);
- if (interval > 0L) mainHandler.postDelayed(this, interval);
- }
- };
-
- /** Requests a backdrop capture if the schedule allows, and hands the result to the wheel. */
- private void requestBackdropCapture() {
- WheelBackdropCapture capture = backdropCapture;
- RadialMenuView view = menuView;
- if (capture == null || view == null) return;
- capture.captureIfDue(activity.getWindow(), android.os.SystemClock.uptimeMillis(),
- view.material() == null ? RadialWheelMaterial.BLUR_FPS_DEFAULT
- : Math.max(1, view.material().blurFps),
- new WheelBackdropCapture.Callback() {
- @Override
- public void onCaptured(android.graphics.Bitmap bitmap) {
- RadialMenuView current = menuView;
- if (current != null) current.setBackdropCapture(bitmap);
- }
-
- @Override
- public void onFailed() {
- // The material resolves to SOLID on the next refresh; nothing else to do.
- RadialMenuView current = menuView;
- if (current != null) current.refreshMaterial();
- }
- });
- }
-
- public void hide() {
- isShowing = false;
- mainHandler.removeCallbacks(backdropRefreshRunnable);
- RadialMenuView view = menuView;
- menuView = null;
- if (view != null) view.playClose();
- // Detach after the short close animation so the reverse plays rather than popping. The
- // callback is posted on the main Handler rather than on the View: a View's own postDelayed
- // is queued behind attachment, so if the window was torn down by anything else first the
- // callback never ran and the full-screen translucent window stayed attached — the frozen
- // "white transparent overlay" over the game. A Handler post always runs.
- mainHandler.removeCallbacks(detachRunnable);
- mainHandler.postDelayed(detachRunnable, 120L);
- }
-
- private final android.os.Handler mainHandler =
- org.chimeramc.client.util.MainThread.handler();
- private final Runnable detachRunnable = this::detachQuietly;
-
- private void detachQuietly() {
- View view = overlayView;
- overlayView = null;
- // Release the backdrop capture and clear the wheel's backdrop layer so nothing is left
- // pointing at a recycled bitmap after the wheel closes (R3/R12).
- if (backdropCapture != null) {
- backdropCapture.release();
- backdropCapture = null;
- }
- RadialMenuView wheel = menuView;
- if (wheel != null) {
- wheel.releaseBackdropLayer();
- }
- if (view == null) return;
- try {
- if (view.getParent() != null) {
- windowManager.removeViewImmediate(view);
- }
- } catch (Exception ignored) {
- AppLog.warnOnce("CaramelClient", "RadialMenuOverlay:581", "Ignored a failure at RadialMenuOverlay:581", ignored);
- }
- // A second, unconditional attempt: removeViewImmediate can throw for a window the system
- // has already detached, and a translucent MATCH_PARENT window that survives that is exactly
- // the stuck overlay. A removeView of a non-attached view is a safe no-op.
- try {
- windowManager.removeView(view);
- } catch (Exception ignored) {
- AppLog.warnOnce("CaramelClient", "RadialMenuOverlay:589", "Ignored a failure at RadialMenuOverlay:589", ignored);
- }
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialMenuView.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialMenuView.java
deleted file mode 100644
index ceb0cd8e..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialMenuView.java
+++ /dev/null
@@ -1,1925 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import org.chimeramc.client.util.AppLog;
-
-import android.animation.ValueAnimator;
-import android.content.Context;
-import android.graphics.Bitmap;
-import android.graphics.Canvas;
-import android.graphics.Paint;
-import android.graphics.Path;
-import android.graphics.Rect;
-import android.graphics.RectF;
-import android.graphics.Typeface;
-import android.util.AttributeSet;
-import android.view.HapticFeedbackConstants;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.animation.PathInterpolator;
-import android.widget.ImageView;
-
-import org.chimeramc.client.R;
-import org.chimeramc.client.core.cosmetics.PlayerSkinProvider;
-import org.chimeramc.client.core.cosmetics.PreviewCharacterScene;
-import org.chimeramc.client.core.cosmetics.PreviewRaster;
-import org.chimeramc.client.core.cosmetics.PreviewScene;
-import org.chimeramc.client.core.cosmetics.SkinModel;
-import org.chimeramc.client.core.mods.inbuilt.overlay.RadialWheelLayout.Sector;
-import org.chimeramc.client.ui.components.PointerCursorRenderer;
-import org.chimeramc.client.ui.components.UiChip;
-import org.chimeramc.client.ui.components.UiDraw;
-import org.chimeramc.client.ui.components.UiIcons;
-import org.chimeramc.client.ui.components.UiTokens;
-
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * The radial mod wheel: four rounded annular sectors around the player's skin head, with floating
- * quick-toggle chips.
- *
- *
Rewritten to the "blueberry" visual language: a scrim, glass sectors with a rim, outline icons
- * over a caps label, a hub disc with the player's real skin head, and chips in three floating
- * clusters. Motion (open scale/alpha with a per-sector stagger, hover scale, press feedback) and
- * haptics are the reference feel; the geometry and the chip layout are pure
- * ({@link RadialWheelLayout}) and unit-tested.
- */
-public class RadialMenuView extends View {
-
- /** Called when the player releases over a destination sector (or taps outside to close). */
- public interface Listener {
- void onSector(Sector sector);
- void onDismiss();
- }
-
- /** Called when a quick chip is tapped, with the module id it toggles. */
- public interface ChipToggle {
- void onChipToggled(String moduleId);
- boolean isChipOn(String moduleId);
- }
-
- /**
- * Called when a profile-driven sector is selected, with the entry that owns it.
- *
- *
Separate from {@link Listener} on purpose: {@code Listener} is the fixed four-destination
- * wheel's contract, while this carries an arbitrary profile entry (a module toggle, a screen, or
- * a sub-wheel). Keeping them apart means the default wheel's routing cannot be broken by a
- * profile feature it does not use.
- */
- public interface EntryListener {
- /** The player selected an entry. */
- void onEntry(WheelEntry entry);
- /** The player backed out of the open sub-wheel. */
- void onBack();
- }
-
- private static final Sector[] SECTORS =
- {Sector.MODS, Sector.CLOSET, Sector.SETTINGS, Sector.HUD};
-
- /**
- * One row of the enabled-module list.
- *
- *
The list shows the modules that are actually on, grouped top-to-bottom on the right, so the
- * wheel tells the player what is active without opening the module grid. Selecting a row toggles
- * its module, which is how the wheel switches things off again.
- */
- static final class Chip {
- final String moduleId;
- final String label;
-
- Chip(String moduleId, String label) {
- this.moduleId = moduleId;
- this.label = label == null ? "" : label;
- }
- }
-
- private final Paint sectorPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint rimPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint labelPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint hubPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint chipPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint chipTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint headPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Paint iconPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
- private final Path sectorPath = new Path();
- private final Path headClip = new Path();
- private final RadialHeadSkin headSkin = new RadialHeadSkin();
- private final RectF dstHead = new RectF();
-
- private final List quickChips = new ArrayList<>();
- private final List chips = new ArrayList<>();
- private Listener listener;
- private ChipToggle chipToggle;
- private EntryListener entryListener;
- private Sector highlighted = Sector.NONE;
- private int accent;
-
- // --- Profile mode ---------------------------------------------------------------------------
- //
- // The wheel is either the fixed four-destination wheel (activeProfile == null) or a
- // profile-driven one. The fixed wheel is left exactly as it was: the profile feature is
- // additive, so a regression here cannot break the shipped routing.
- private WheelEntry.Profile activeProfile;
- private final WheelNavigation navigation = new WheelNavigation();
- private WheelProfileStore profileStore;
- /** The profile-entry index the pointer or cursor is over, or -1. */
- private int highlightedEntry = -1;
-
- /** The controller cursor, non-null only while a pad is driving the wheel. */
- private CursorController cursor;
- /** The chip currently under the controller cursor, or -1. */
- private int cursorChip = -1;
- /** Caches the pointer outline so a per-frame draw allocates nothing (Phase 12). */
- private final PointerCursorRenderer cursorRenderer = new PointerCursorRenderer();
- /** True while the cursor is visible (a touch hides it). */
- private boolean cursorVisible;
-
- /** Tracks release velocity so a flick toward a slice can commit it (R: flick-to-select). */
- private android.view.VelocityTracker flickTracker;
-
- private float density;
- /** The screen-reader virtual-view bridge for the wheel's slots. */
- private RadialWheelAccessibilityHelper a11yHelper;
-
-
- // The 3D head hub: a small software-rendered turntable of the player's real skin head.
- private final PreviewRaster headRaster = new PreviewRaster();
- private Bitmap headBitmap;
- private float headYawDeg;
- private float headPitchDeg;
- private long headLastMs;
-
- // --- Motion state -------------------------------------------------------------------------
-
- private float openProgress; // 0 closed .. 1 open
- private final float[] sectorScale = new float[]{1f, 1f, 1f, 1f};
- private ValueAnimator openAnimator;
- private ValueAnimator hoverAnimator;
-
- // --- Glass material (R2, R8, R9, R10, R12) -----------------------------------------------
-
- /** The glass renderer: the only place the API-level blur lives. */
- private final WheelGlassRenderer glassRenderer = new WheelGlassRenderer();
- /** The blurred backdrop layer (R3); supplied by the host, null when the wheel is a plain child. */
- private ImageView backdropLayer;
- /** The resolved material for this open, recomputed when the environment changes. */
- private RadialWheelMaterial.Resolved material = RadialWheelMaterial.resolve(
- RadialWheelMaterial.Tier.REGULAR, null);
- /** The backdrop capture the stack-blur draws under the fill (API 28-30 only). */
- private Bitmap backdropCapture;
- /** Supplies the device/environment state; the host installs it. */
- private MaterialEnvProvider materialEnvProvider;
- /** The most recent backdrop sample, used for the contrast floor (R9). */
- private int backdropSample = 0xFF070A0F;
-
- /** Supplies the environment the material resolves against. */
- public interface MaterialEnvProvider {
- RadialWheelMaterial.Env env();
- }
-
- // --- Cancel gesture (R11) -----------------------------------------------------------------
-
- /** True while a drag past the outer radius has armed the cancel. */
- private boolean cancelArmed;
- private float cancelOpacity = 1f;
- private float cancelScale = 1f;
-
- // --- Cached sector paths (R13) ------------------------------------------------------------
-
- /** The four resting sector paths, rebuilt only when the geometry changes. */
- private final Path[] cachedSectorPaths = new Path[4];
- private boolean sectorPathsValid;
- private float cachedPathOuter = -1f;
- private float cachedPathInner = -1f;
-
- // --- Cached profile-sector paths (R13) ----------------------------------------------------
-
- /**
- * The profile wheel's sector paths, one per possible sector (R13).
- *
- * The profile wheel's count changes with the open profile, so it cannot reuse the fixed
- * four-path cache above. These are rebuilt only when the count or the ring's radii change —
- * during the open animation the radii move every frame, so the cache is inert then and takes
- * effect the moment the ring settles, which is the steady state the player actually sees.
- */
- private final Path[] cachedProfilePaths = new Path[WheelLayout.MAX_SECTORS];
- private boolean profilePathsValid;
- private int cachedProfileCount = -1;
- private float cachedProfileInner = -1f;
- private float cachedProfileOuter = -1f;
-
- private static final PathInterpolator FAST_OUT_SLOW_IN =
- new PathInterpolator(0.4f, 0f, 0.2f, 1f);
-
- /**
- * How square the segment caps are (R1): 1 is a full squircle, 0 a circular fillet. Slightly
- * under 1 keeps the caps from reading as sharp corners at small radii.
- */
- private static final float SQUIRCLE_SQUARENESS = 0.85f;
-
- /**
- * The backdrop disc's radius as a multiple of the ring's outer radius (R2): the sheet extends a
- * little past the ring so the segments read as sitting on a panel rather than floating.
- */
- private static final float BACKDROP_RADIUS_SCALE = 1.12f;
-
- public RadialMenuView(Context context) {
- this(context, null);
- }
-
- public RadialMenuView(Context context, AttributeSet attrs) {
- super(context, attrs);
- setWillNotDraw(false);
- setClickable(true);
- setFocusable(false);
- density = getResources().getDisplayMetrics().density;
- accent = UiTokens.resolveAccent(context);
- buildChips();
- resolveSkinHead();
- labelPaint.setTextAlign(Paint.Align.CENTER);
- // R6: the segment label is 11sp at wght 500, one line. The weight is a variable-font axis,
- // not a separate typeface file: setting it on the paint is what makes the 500 real rather
- // than a comment.
- labelPaint.setTypeface(Typeface.create(Typeface.DEFAULT,
- RadialWheelMotion.LABEL_WEIGHT, false));
- labelPaint.setTextSize(RadialWheelMotion.LABEL_SP * density);
- applyLabelWeight(labelPaint, RadialWheelMotion.LABEL_WEIGHT);
- a11yHelper = new RadialWheelAccessibilityHelper(this);
- setFocusable(true);
- }
-
- /** The accessibility bridge, exposed so the host can forward hover events. */
- RadialWheelAccessibilityHelper accessibilityHelper() {
- return a11yHelper;
- }
-
- @Override
- protected boolean dispatchHoverEvent(android.view.MotionEvent event) {
- // The bridge needs the hover stream to resolve which virtual slot the pointer is over; a
- // touch-exploring screen reader drives the wheel through this path, not through onTouchEvent.
- if (a11yHelper != null && a11yHelper.dispatchHoverEvent(event)) return true;
- return super.dispatchHoverEvent(event);
- }
-
- public void setListener(Listener listener) {
- this.listener = listener;
- }
-
- /**
- * Installs the environment provider and resolves the material (R2/R8/R10/R12).
- *
- *
The wheel owns no device state: the host supplies the API level, the accessibility flags,
- * the thermal reading and whether a session is running, and this resolves them once per open.
- */
- public void setMaterialEnvProvider(MaterialEnvProvider provider) {
- this.materialEnvProvider = provider;
- refreshMaterial();
- }
-
- /**
- * Hands the wheel the layer it paints the blurred backdrop on (R3).
- *
- *
The layer is a sibling behind the wheel in the overlay's {@code FrameLayout}. On API 31+
- * the blur is a {@code RenderEffect} on that layer, which is the only place it can go without
- * blurring the wheel's own content; on API 28-30 the layer stays hidden and the wheel blurs the
- * capture into its own canvas instead.
- */
- public void setBackdropLayer(ImageView layer) {
- this.backdropLayer = layer;
- refreshMaterial();
- }
-
- /** Re-resolves the glass material from the current environment and applies the blur. */
- public void refreshMaterial() {
- RadialWheelMaterial.Env env = materialEnvProvider == null
- ? new RadialWheelMaterial.Env() : materialEnvProvider.env();
- if (env == null) env = new RadialWheelMaterial.Env();
- material = RadialWheelMaterial.resolveForBackdrop(
- RadialWheelMaterial.tierFor(RadialWheelMaterial.Role.PANEL), env,
- UiTokens.TEXT_PRIMARY, backdropSample);
- applyBackdropLayer();
- invalidate();
- }
-
- /** Pushes the resolved material and the current capture onto the backdrop layer (R3). */
- private void applyBackdropLayer() {
- glassRenderer.applyBackdrop(backdropLayer, material, backdropCapture, density);
- }
-
- /**
- * Releases the backdrop layer's bitmap and effect (R3).
- *
- *
Called when the overlay detaches. The wheel itself does not own the layer's lifetime, so
- * this must not be left to garbage collection: the layer would keep the last capture alive, and
- * a capture the host has already recycled would leave the layer pointing at dead pixels.
- */
- public void releaseBackdropLayer() {
- glassRenderer.releaseBackdrop(backdropLayer);
- backdropCapture = null;
- backdropLayer = null;
- }
-
- /**
- * Hands the wheel a backdrop sample for the contrast floor (R9/R3).
- *
- *
The capture is the window the wheel sits over, already downscaled; the view only reads one
- * pixel to decide whether the labels need a heavier scrim. A null or recycled bitmap clears the
- * sample and the fixed floor applies.
- */
- public void setBackdropCapture(Bitmap capture) {
- if (backdropCapture != null && backdropCapture != capture && !backdropCapture.isRecycled()) {
- backdropCapture.recycle();
- }
- backdropCapture = capture;
- if (capture != null && !capture.isRecycled()
- && capture.getWidth() > 0 && capture.getHeight() > 0) {
- try {
- backdropSample = capture.getPixel(capture.getWidth() / 2, capture.getHeight() / 2);
- } catch (Throwable ignored) {
- backdropSample = 0xFF070A0F;
- }
- }
- refreshMaterial();
- }
-
- /** The resolved glass material; exposed so the host can assert the fallback ladder. */
- public RadialWheelMaterial.Resolved material() {
- return material;
- }
-
- public void setChipToggle(ChipToggle chipToggle) {
- this.chipToggle = chipToggle;
- invalidate();
- }
-
- public void setEntryListener(EntryListener entryListener) {
- this.entryListener = entryListener;
- }
-
- /**
- * Switches the wheel to a profile-driven one.
- *
- *
Loads the profiles, seeds the navigation at the root and draws the root's entries. Passing
- * null returns the wheel to the fixed four-destination layout, so a caller that does not want
- * profiles is unaffected.
- */
- public void setProfiles(Context context) {
- try {
- profileStore = new WheelProfileStore(context);
- navigation.reset(profileStore.getRootId());
- activeProfile = profileStore.rootProfile();
- } catch (Throwable t) {
- profileStore = null;
- activeProfile = null;
- }
- highlightedEntry = -1;
- invalidate();
- }
-
- /** True while the wheel is drawing a profile rather than the fixed four destinations. */
- public boolean isProfileMode() {
- return activeProfile != null;
- }
-
- /** The profile currently open (the top of the navigation stack). */
- WheelEntry.Profile currentProfile() {
- if (profileStore == null) return activeProfile;
- WheelEntry.Profile p = WheelProfileStore.findById(profileStore.load(), navigation.currentId());
- return p == null ? activeProfile : p;
- }
-
- /**
- * Opens a sub-wheel by profile id.
- *
- * @return true when the profile exists and the stack accepted it
- */
- boolean openSubWheel(String id) {
- if (profileStore == null || id == null) return false;
- if (WheelProfileStore.findById(profileStore.load(), id) == null) return false;
- if (!navigation.push(id)) return false;
- highlightedEntry = -1;
- playOpen();
- return true;
- }
-
- /**
- * Backs out one level.
- *
- * @return true when a sub-wheel was closed; false at the root, where the caller dismisses
- */
- boolean backOut() {
- if (!navigation.pop()) return false;
- highlightedEntry = -1;
- playOpen();
- return true;
- }
-
- private void buildChips() {
- // Left column: the quick module options the player can turn on from the wheel. Curated (a
- // short list of the modules that are useful mid-game), so the wheel stays readable.
- quickChips.clear();
- quickChips.add(new Chip("zoom", "Zoom"));
- quickChips.add(new Chip("fullbright", "Fullbright"));
- quickChips.add(new Chip("cps_display", "CPS"));
- quickChips.add(new Chip("fps_display", "FPS"));
- quickChips.add(new Chip("list_players", "Players"));
- quickChips.add(new Chip("debug_menu", "Debug"));
- // Right column: filled live from the enabled modules (see rebuildEnabledList).
- chips.clear();
- }
-
- /**
- * Rebuilds the enabled-module list from the live provider.
- *
- *
Order follows the provider's own list, so the wheel and the Mod Menu show the same order.
- * Modules are shown with their display name; a module that is registered but not enabled is not
- * listed, because the list's job is "what is on right now".
- */
- private void rebuildEnabledList() {
- chips.clear();
- try {
- // InbuiltModuleProvider.load needs an Activity; the wheel is only ever shown over one.
- android.app.Activity activity = null;
- android.content.Context ctx = getContext();
- while (ctx instanceof android.content.ContextWrapper) {
- if (ctx instanceof android.app.Activity) {
- activity = (android.app.Activity) ctx;
- break;
- }
- ctx = ((android.content.ContextWrapper) ctx).getBaseContext();
- }
- if (activity == null) return;
- List mods =
- org.chimeramc.client.core.mods.inbuilt.InbuiltModuleProvider
- .load(activity);
- for (org.chimeramc.client.core.mods.inbuilt.UnifiedMod mod : mods) {
- if (mod == null || !mod.isEnabled()) continue;
- // Only modules that carry a name are listed; an extension without a label cannot be
- // drawn as a row.
- String name = mod.getName();
- if (name == null || name.trim().isEmpty()) continue;
- chips.add(new Chip(mod.getStableKey(), name));
- }
- } catch (Throwable ignored) {
- chips.clear();
- }
- }
-
- private void resolveSkinHead() {
- // The head atlas, the crop rectangle and the head-only scene all live in RadialHeadSkin.
- headSkin.resolve(getContext());
- }
-
- // --- Open/close animation -----------------------------------------------------------------
-
- /** Runs the open animation (scrim fade + ring scale/alpha with a stagger). */
- public void playOpen() {
- cancelAnimators();
- sectorPathsValid = false;
- profilePathsValid = false;
- openAnimator = ValueAnimator.ofFloat(0f, 1f);
- // R10: reduced motion runs a short linear transition rather than the spring.
- if (material != null && material.reducedMotion) {
- openAnimator.setDuration(RadialWheelMotion.REDUCED_MS);
- openAnimator.setInterpolator(new android.view.animation.LinearInterpolator());
- } else {
- openAnimator.setDuration(UiTokens.MOTION_DEFAULT_MS + 20);
- openAnimator.setInterpolator(FAST_OUT_SLOW_IN);
- }
- openAnimator.addUpdateListener(a -> {
- openProgress = (float) a.getAnimatedValue();
- invalidate();
- });
- openAnimator.start();
- }
-
- /** Runs the close animation. */
- public void playClose() {
- cancelAnimators();
- sectorPathsValid = false;
- profilePathsValid = false;
- openAnimator = ValueAnimator.ofFloat(openProgress, 0f);
- openAnimator.setDuration(material != null && material.reducedMotion
- ? RadialWheelMotion.REDUCED_MS : 120);
- openAnimator.setInterpolator(material != null && material.reducedMotion
- ? new android.view.animation.LinearInterpolator() : FAST_OUT_SLOW_IN);
- openAnimator.addUpdateListener(a -> {
- openProgress = (float) a.getAnimatedValue();
- invalidate();
- });
- openAnimator.start();
- }
-
- private void cancelAnimators() {
- if (openAnimator != null) openAnimator.cancel();
- if (hoverAnimator != null) hoverAnimator.cancel();
- }
-
- /**
- * Woken when a background account-skin fetch finishes.
- *
- * The first resolve after a sign-in schedules the fetch and shows the fallback, so without
- * this the player's real skin only appeared the next time the wheel was created. Registered
- * while attached and removed on detach, so a discarded view is never woken.
- */
- private final org.chimeramc.client.core.cosmetics.AccountSkinService.Listener skinListener =
- () -> post(this::refreshSkin);
-
- @Override
- protected void onAttachedToWindow() {
- super.onAttachedToWindow();
- org.chimeramc.client.core.cosmetics.AccountSkinService.addListener(skinListener);
- // Re-resolve the skin so a change made since the wheel was last shown (a new import, an
- // applied pack, or the game's own skin once a provider is installed) appears immediately.
- refreshSkin();
- // Rebuild the enabled-module list so a toggle made while the wheel was closed shows now.
- rebuildEnabledList();
- // The head is deliberately NOT animated on a timer: it turns only when the player moves the
- // cursor, so the hub reads as a still portrait until it is interacted with. Reset the look
- // to forward-facing so a previous session leaves no stare. Nothing schedules a frame here,
- // which also means the view burns no frames while it sits open.
- headYawDeg = 0f;
- headPitchDeg = 0f;
- headLastMs = android.os.SystemClock.uptimeMillis();
- playOpen();
- }
-
- /**
- * Re-resolves the player's skin and rebuilds the hub head.
- *
- *
Called when the wheel is shown and when a skin change is detected, so the head in the
- * centre tracks the player's real skin without recreating the view. It reads through
- * {@link PlayerSkinProvider}, which prefers the game's live skin when a
- * {@link org.chimeramc.client.core.cosmetics.GameSkinSource} provider is installed.
- */
- public void refreshSkin() {
- resolveSkinHead();
- invalidate();
- }
-
- @Override
- protected void onDetachedFromWindow() {
- super.onDetachedFromWindow();
- org.chimeramc.client.core.cosmetics.AccountSkinService.removeListener(skinListener);
- cancelAnimators();
- stopFlickTracker();
- }
-
- // --- Geometry helpers (shared by draw and touch) ----------------------------------------
-
- private float centerX() {
- return getWidth() / 2f;
- }
-
- private float centerY() {
- return getHeight() / 2f;
- }
-
- private float outerRadius() {
- return RadialWheelLayout.outerRadius(getWidth(), getHeight());
- }
-
- private float ringInnerRadius() {
- return RadialWheelLayout.ringInnerRadius(outerRadius());
- }
-
- private float hubRadius() {
- return RadialWheelLayout.hubRadius(outerRadius());
- }
-
- // --- Draw ---------------------------------------------------------------------------------
-
- @Override
- protected void onDraw(Canvas canvas) {
- super.onDraw(canvas);
- if (getWidth() <= 0 || getHeight() <= 0) return;
- float cx = centerX();
- float cy = centerY();
- // R4: the ring springs from 0.86 to 1.0 on open; R11 dims and shrinks it while a cancel
- // drag is armed. Both factors come from the pure motion rules.
- float openScale = RadialWheelMotion.openScale(openProgress);
- float outer = outerRadius() * openScale * cancelScale;
- float inner = RadialWheelLayout.ringInnerRadius(outer);
- float hub = RadialWheelLayout.hubRadius(outer);
- int alpha = (int) (255 * Math.min(1f, openProgress * 1.4f) * cancelOpacity);
-
- // The scrim is the material's floor (R2), not an ad-hoc wash: an opaque layer was what left
- // a pale film over the game after the wheel closed, so the alpha stays tied to openProgress
- // and the floor is the minimum the labels need over a bright frame.
- int scrimAlpha = (int) (material.scrimAlpha * 255 * openProgress);
- if (scrimAlpha > 0) {
- canvas.drawColor(UiTokens.withAlpha(0xFF000000, scrimAlpha));
- }
- // R2/R3: the glass sheet behind the ring, drawn from the resolved material (the tier's fill,
- // its white tint, and the stack-blurred capture below API 31). Drawn before the sectors so
- // the ring sits on the sheet rather than beside it.
- glassRenderer.drawBackdrop(canvas, material, backdropCapture, cx, cy,
- outer * BACKDROP_RADIUS_SCALE, openProgress);
-
- // Sectors.
- if (activeProfile != null) {
- drawProfileSectors(canvas, cx, cy, inner, outer, alpha);
- } else {
- for (int i = 0; i < SECTORS.length; i++) {
- Sector s = SECTORS[i];
- float center = (float) RadialWheelLayout.sectorCenterDegrees(s);
- // Per-sector stagger: each starts 20ms after the previous.
- float local = Math.max(0f, Math.min(1f, (openProgress * 180f - i * 20f) / 120f));
- float scale = sectorScale[i] * (0.9f + 0.1f * local);
- float rOuter = outer * scale;
- float rInner = inner * scale;
- drawSector(canvas, cx, cy, rInner, rOuter, center - 45f, 90f,
- s == highlighted, alpha, local, i);
- }
- }
-
- // Hub (the player's head only).
- drawHub(canvas, cx, cy, hub, alpha);
-
- // Icons + labels.
- if (activeProfile != null) {
- drawProfileContent(canvas, cx, cy, (inner + outer) / 2f, alpha);
- } else {
- drawSectorContent(canvas, cx, cy, (inner + outer) / 2f, alpha);
- }
-
- // The enabled-module list (right side) and the controller cursor.
- drawEnabledList(canvas, getWidth(), getHeight(), alpha);
- drawControllerCursor(canvas, alpha);
- }
-
- /** Draws the controller cursor as a clean pointer arrow with a small shadow. */
- private void drawControllerCursor(Canvas canvas, int alpha) {
- if (!isCursorVisible()) return;
- Paint shadow = iconPaint;
- Paint arrow = chipPaint;
- cursorRenderer.prepare(cursor.x(), cursor.y(),
- PointerCursorRenderer.HEIGHT_DP * density);
- UiDraw.pointerCursor(canvas, cursorRenderer.path(), shadow, arrow, density);
- }
-
- private void drawSector(Canvas canvas, float cx, float cy, float rInner, float rOuter,
- float startDeg, float sweepDeg, boolean hover, int alpha,
- float localProgress, int index) {
- float gapPx = RadialWheelLayout.GAP_DP * density;
- // Parallel-sided gap: inset the angular span by the half-gap at the mid radius.
- float midR = (rInner + rOuter) / 2f;
- float halfGapDeg = (float) RadialWheelLayout.angularHalfGapDegrees(midR, gapPx);
- float corner = RadialWheelLayout.SECTOR_CORNER_DP * density;
-
- // R13: the four resting paths are built once and reused. Only the highlighted sector (whose
- // radius is scaled by the hover/press animation) is rebuilt per frame, so a steady open
- // wheel allocates nothing and re-tessellates nothing.
- Path path;
- if (!hover && index >= 0 && index < cachedSectorPaths.length) {
- if (!sectorPathsValid || cachedPathOuter != rOuter || cachedPathInner != rInner) {
- buildSectorPathCache(cx, cy, rInner, rOuter, corner, halfGapDeg, sweepDeg);
- }
- if (cachedSectorPaths[index] == null) {
- cachedSectorPaths[index] = new Path();
- sectorPath.reset();
- buildRoundedAnnularSector(sectorPath, cx, cy, rInner, rOuter,
- startDeg + halfGapDeg, sweepDeg - 2f * halfGapDeg, corner);
- cachedSectorPaths[index].set(sectorPath);
- }
- path = cachedSectorPaths[index];
- } else {
- sectorPath.reset();
- buildRoundedAnnularSector(sectorPath, cx, cy, rInner, rOuter,
- startDeg + halfGapDeg, sweepDeg - 2f * halfGapDeg, corner);
- path = sectorPath;
- }
-
- // R2: the sector fill is the resolved material's fill and tint, so the glass thickness is
- // one decision for the whole wheel rather than a literal per draw call.
- int base = hover ? UiTokens.WHEEL_SEGMENT_HOVER : UiTokens.WHEEL_SEGMENT_BASE;
- int fill = UiTokens.withAlpha(base, material.fillAlpha);
- sectorPaint.setStyle(Paint.Style.FILL);
- sectorPaint.setColor(UiTokens.withAlpha(fill,
- (int) (((fill >>> 24) & 0xFF) * localProgress)));
- canvas.drawPath(path, sectorPaint);
-
- // The white tint catches the light on the sheet (R2).
- int tintAlpha = (int) (material.tint * 255 * localProgress);
- if (tintAlpha > 0) {
- rimPaint.setStyle(Paint.Style.FILL);
- rimPaint.setColor(UiTokens.withAlpha(0xFFFFFFFF, tintAlpha));
- canvas.drawPath(path, rimPaint);
- }
-
- rimPaint.setStyle(Paint.Style.STROKE);
- // R10: a solid tier (reduce transparency, no blur) needs a real 1dp border rather than the
- // faint glass hairline, because there is no blur to define the edge.
- boolean solidTier = material.tier == RadialWheelMaterial.Tier.SOLID;
- rimPaint.setStrokeWidth(Math.max(1f, density) * (solidTier ? 1.5f : 1f));
- rimPaint.setColor(UiTokens.withAlpha(hover ? accent : UiTokens.WHEEL_SEGMENT_RIM,
- (int) ((hover ? 0xAA : (solidTier ? 0x44 : 0x24)) * localProgress)));
- canvas.drawPath(path, rimPaint);
- }
-
- /** Builds the four resting sector paths for the current geometry (R13). */
- private void buildSectorPathCache(float cx, float cy, float rInner, float rOuter, float corner,
- float halfGapDeg, float sweepDeg) {
- for (int i = 0; i < SECTORS.length && i < cachedSectorPaths.length; i++) {
- if (cachedSectorPaths[i] == null) cachedSectorPaths[i] = new Path();
- float start = (float) RadialWheelLayout.sectorCenterDegrees(SECTORS[i]) - sweepDeg / 2f;
- cachedSectorPaths[i].reset();
- buildRoundedAnnularSector(cachedSectorPaths[i], cx, cy, rInner, rOuter,
- start + halfGapDeg, sweepDeg - 2f * halfGapDeg, corner);
- }
- cachedPathOuter = rOuter;
- cachedPathInner = rInner;
- sectorPathsValid = true;
- }
-
- // --- Profile mode drawing -----------------------------------------------------------------
-
- /**
- * Draws the open profile's sectors.
- *
- *
The sector count follows the entry count (one to {@link WheelLayout#MAX_SECTORS}), and each
- * sector takes the entry's own colour when it has one, so a profile with four entries looks like
- * the fixed wheel while an eight-entry one is visibly denser. A single entry is drawn as a full
- * ring, matching {@link WheelLayout#sectorAt}'s "one sector owns the whole ring" rule.
- */
- private void drawProfileSectors(Canvas canvas, float cx, float cy, float inner, float outer,
- int alpha) {
- WheelEntry.Profile profile = currentProfile();
- if (profile == null) return;
- List entries = profile.safeEntries();
- int count = usableSectorCount(entries, outer);
- if (entries.isEmpty()) return;
-
- if (count == 1) {
- // A one-entry wheel is a ring: draw the annulus with no seam.
- drawRingSector(canvas, cx, cy, inner, outer, entries.get(0), 0 == highlightedEntry,
- alpha, 1f);
- return;
- }
-
- double span = WheelLayout.sectorSpanDegrees(count);
- for (int i = 0; i < entries.size() && i < count; i++) {
- float local = Math.max(0f, Math.min(1f, (openProgress * 180f - i * 20f) / 120f));
- float scale = 0.9f + 0.1f * local;
- float start = (float) (WheelLayout.sectorCenterDegrees(i, count) - span / 2.0);
- drawSectorColored(canvas, cx, cy, inner * scale, outer * scale, start, (float) span,
- i == highlightedEntry, alpha, local, entries.get(i).effectiveColor(accent), i);
- }
- }
-
- /** Draws a profile sector with a per-entry fill colour (the four-destination path's variant). */
- private void drawSectorColored(Canvas canvas, float cx, float cy, float rInner, float rOuter,
- float startDeg, float sweepDeg, boolean hover, int alpha,
- float localProgress, int entryColor, int index) {
- Path path = profileSectorPath(cx, cy, rInner, rOuter, startDeg, sweepDeg, index);
-
- int base = hover ? UiTokens.WHEEL_SEGMENT_HOVER : UiTokens.WHEEL_SEGMENT_BASE;
- // Blend the entry's colour into the glass fill so a coloured sector still reads as glass.
- int fill = blend(UiTokens.withAlpha(entryColor, 0x66), base);
- sectorPaint.setStyle(Paint.Style.FILL);
- sectorPaint.setColor(UiTokens.withAlpha(fill,
- (int) (((fill >>> 24) & 0xFF) * localProgress)));
- canvas.drawPath(path, sectorPaint);
-
- rimPaint.setStyle(Paint.Style.STROKE);
- rimPaint.setStrokeWidth(Math.max(1f, density));
- rimPaint.setColor(UiTokens.withAlpha(hover ? entryColor : UiTokens.WHEEL_SEGMENT_RIM,
- (int) ((hover ? 0xAA : 0x24) * localProgress)));
- canvas.drawPath(path, rimPaint);
- }
-
- /**
- * Returns the cached path for a profile sector, rebuilding the cache when the ring moves (R13).
- *
- * A profile sector's path is a pure function of the count, the radii and its own index, so it
- * only needs rebuilding when one of those changes. During the open animation the radii change
- * every frame, so the cache misses then; once the ring settles it hits, and the per-frame path
- * construction — a full arc with two quadratic corner fillets per sector — stops happening.
- */
- private Path profileSectorPath(float cx, float cy, float rInner, float rOuter, float startDeg,
- float sweepDeg, int index) {
- int count = WheelLayout.clampSectorCount(currentProfile() == null ? 1
- : currentProfile().safeEntries().size());
- boolean cacheable = index >= 0 && index < cachedProfilePaths.length;
- if (!cacheable) {
- sectorPath.reset();
- buildProfileSector(sectorPath, cx, cy, rInner, rOuter, startDeg, sweepDeg);
- return sectorPath;
- }
- if (!profilePathsValid || cachedProfileCount != count
- || cachedProfileInner != rInner || cachedProfileOuter != rOuter) {
- rebuildProfilePaths(cx, cy, rInner, rOuter, count);
- }
- Path path = cachedProfilePaths[index];
- if (path == null) {
- path = new Path();
- cachedProfilePaths[index] = path;
- buildProfileSector(path, cx, cy, rInner, rOuter, startDeg, sweepDeg);
- }
- return path;
- }
-
- /** Rebuilds every profile sector path for the current count and radii (R13). */
- private void rebuildProfilePaths(float cx, float cy, float rInner, float rOuter, int count) {
- double span = WheelLayout.sectorSpanDegrees(count);
- for (int i = 0; i < count && i < cachedProfilePaths.length; i++) {
- if (cachedProfilePaths[i] == null) cachedProfilePaths[i] = new Path();
- cachedProfilePaths[i].reset();
- float start = (float) (WheelLayout.sectorCenterDegrees(i, count) - span / 2.0);
- buildProfileSector(cachedProfilePaths[i], cx, cy, rInner, rOuter, start, (float) span);
- }
- // Drop any stale paths from a larger count so a shrunk wheel cannot draw a ghost sector.
- for (int i = count; i < cachedProfilePaths.length; i++) {
- if (cachedProfilePaths[i] != null) cachedProfilePaths[i].reset();
- }
- cachedProfileCount = count;
- cachedProfileInner = rInner;
- cachedProfileOuter = rOuter;
- profilePathsValid = true;
- }
-
- /** Builds one rounded annular sector into {@code out}. */
- private void buildProfileSector(Path out, float cx, float cy, float rInner, float rOuter,
- float startDeg, float sweepDeg) {
- float gapPx = WheelLayout.GAP_DP * density;
- float midR = (rInner + rOuter) / 2f;
- float halfGapDeg = (float) WheelLayout.angularHalfGapDegrees(midR, gapPx);
- float corner = RadialWheelLayout.SECTOR_CORNER_DP * density;
- buildRoundedAnnularSector(out, cx, cy, rInner, rOuter,
- startDeg + halfGapDeg, sweepDeg - 2f * halfGapDeg, corner);
- }
-
- /** Draws the single-entry wheel as one full ring. */
- private void drawRingSector(Canvas canvas, float cx, float cy, float rInner, float rOuter,
- WheelEntry entry, boolean hover, int alpha, float localProgress) {
- float corner = RadialWheelLayout.SECTOR_CORNER_DP * density;
- sectorPath.reset();
- buildRoundedAnnularSector(sectorPath, cx, cy, rInner, rOuter, 0f, 360f, corner);
- int base = hover ? UiTokens.WHEEL_SEGMENT_HOVER : UiTokens.WHEEL_SEGMENT_BASE;
- int fill = blend(UiTokens.withAlpha(entry.effectiveColor(accent), 0x66), base);
- sectorPaint.setStyle(Paint.Style.FILL);
- sectorPaint.setColor(UiTokens.withAlpha(fill,
- (int) (((fill >>> 24) & 0xFF) * localProgress)));
- canvas.drawPath(sectorPath, sectorPaint);
- }
-
- /** Draws each profile sector's label in its own colour, at the sector's centre. */
- private void drawProfileContent(Canvas canvas, float cx, float cy, float radius, int alpha) {
- WheelEntry.Profile profile = currentProfile();
- if (profile == null) return;
- List entries = profile.safeEntries();
- int count = usableSectorCount(entries, outerRadius());
- if (entries.isEmpty()) return;
-
- float labelSize = RadialWheelMotion.LABEL_SP * density;
- labelPaint.setTextSize(labelSize);
- for (int i = 0; i < entries.size() && i < count; i++) {
- double a = Math.toRadians(WheelLayout.sectorCenterDegrees(i, count));
- float px = cx + (float) (Math.cos(a) * radius);
- float py = cy + (float) (Math.sin(a) * radius);
- // A sub-wheel entry gets a chevron instead of an icon so "this opens another wheel" is
- // visible before it is selected.
- UiIcons.Icon icon = entries.get(i).isSubWheel() ? UiIcons.Icon.CHEVRON : iconFor(entries.get(i));
- UiIcons.draw(canvas, iconPaint, icon, px, py - labelSize * 0.6f,
- RadialWheelMotion.ICON_DP * density,
- UiTokens.withAlpha(entries.get(i).effectiveColor(accent), alpha), density);
- String label = RadialWheelMotion.ellipsise(entries.get(i).label);
- if (material.outlinedLabels) {
- Paint outline = glassRenderer.labelOutlinePaint(labelPaint, 0xFF000000, density);
- outline.setTextAlign(Paint.Align.CENTER);
- outline.setTextSize(labelSize);
- canvas.drawText(label, px, py + labelSize * 0.9f, outline);
- }
- labelPaint.setColor(UiTokens.withAlpha(UiTokens.TEXT_PRIMARY, alpha));
- canvas.drawText(label, px, py + labelSize * 0.9f, labelPaint);
- }
- // The open profile's name, so a nested wheel says where it is.
- if (profile.name != null && !profile.name.isEmpty() && !navigation.atRoot()) {
- labelPaint.setTextSize(UiTokens.TEXT_CAPTION * density);
- labelPaint.setColor(UiTokens.withAlpha(UiTokens.TEXT_SECONDARY, alpha));
- canvas.drawText(profile.name, cx, cy + RadialWheelLayout.hubRadius(outerRadius()) * 1.5f,
- labelPaint);
- }
- }
-
- /** The icon for a profile entry, by action. */
- private static UiIcons.Icon iconFor(WheelEntry entry) {
- if (entry == null) return UiIcons.Icon.CUBE;
- switch (entry.action) {
- case OPEN_MODS: return UiIcons.Icon.CUBE;
- case OPEN_CLOSET: return UiIcons.Icon.HANGER;
- case OPEN_SETTINGS: return UiIcons.Icon.GEAR;
- case OPEN_HUD: return UiIcons.Icon.PENCIL;
- case OPEN_SUBWHEEL: return UiIcons.Icon.CHEVRON;
- case SWITCH_LAYOUT: return UiIcons.Icon.HUD;
- default: return UiIcons.Icon.CUBE;
- }
- }
-
- /** Alpha-blends {@code over} onto {@code under} using the over's alpha. */
- private static int blend(int over, int under) {
- float a = ((over >>> 24) & 0xFF) / 255f;
- int r = (int) (((over >> 16) & 0xFF) * a + ((under >> 16) & 0xFF) * (1 - a));
- int g = (int) (((over >> 8) & 0xFF) * a + ((under >> 8) & 0xFF) * (1 - a));
- int b = (int) ((over & 0xFF) * a + (under & 0xFF) * (1 - a));
- int outA = Math.max((over >>> 24) & 0xFF, (under >>> 24) & 0xFF);
- return (outA << 24) | (r << 16) | (g << 8) | b;
- }
-
- /** Builds a rounded annular sector as one closed path (outer arc, fillet, inner arc, fillet). */
- private void buildRoundedAnnularSector(Path path, float cx, float cy, float rInner,
- float rOuter, float startDeg, float sweepDeg,
- float corner) {
- float r = Math.min(corner, Math.min((rOuter - rInner) / 2f, Math.abs(sweepDeg) / 4f));
- float oInset = (float) Math.toDegrees(r / Math.max(1f, rOuter));
- float iInset = (float) Math.toDegrees(r / Math.max(1f, rInner));
- float a0 = startDeg;
- float a1 = startDeg + sweepDeg;
-
- path.moveTo(polarX(cx, rOuter, a0 + oInset), polarY(cy, rOuter, a0 + oInset));
- arc(path, cx, cy, rOuter, a0 + oInset, a1 - oInset);
- // R1: the cap is a squircle, not a circular fillet. A cubic with its control points pulled
- // toward the corner by the squircle ratio gives the flat-sided cap the design asks for; a
- // quadTo fillet reads as a plain rounded rectangle.
- squircleCap(path, cx, cy, rOuter, rInner, a1, oInset, iInset, r, false);
- arc(path, cx, cy, rInner, a1 - iInset, a0 + iInset);
- squircleCap(path, cx, cy, rOuter, rInner, a0, oInset, iInset, r, true);
- path.close();
- }
-
- /**
- * Adds one squircle end cap from the outer edge to the inner edge (R1).
- *
- * The control points sit along each edge's tangent, offset by the fillet radius scaled by
- * {@link RadialWheelMaterial#squircleControl}, which is what makes the cap flat-sided rather than
- * a quarter circle. {@code fromInner} selects the direction of travel.
- */
- private void squircleCap(Path path, float cx, float cy, float rOuter, float rInner, float angle,
- float oInset, float iInset, float r, boolean fromInner) {
- float k = RadialWheelMaterial.squircleControl(SQUIRCLE_SQUARENESS) * r;
- float aOut = fromInner ? angle + oInset : angle - oInset;
- float aIn = fromInner ? angle - iInset : angle + iInset;
- float ox = polarX(cx, rOuter, aOut), oy = polarY(cy, rOuter, aOut);
- float ix = polarX(cx, rInner, aIn), iy = polarY(cy, rInner, aIn);
- // The tangent of a circle at angle a is (sin a, -cos a) when the angle decreases.
- double aRad = Math.toRadians(angle);
- float tx = (float) Math.sin(aRad), ty = (float) -Math.cos(aRad);
- float c1x = ox + tx * k, c1y = oy + ty * k;
- float c2x = ix - tx * k, c2y = iy - ty * k;
- if (fromInner) {
- path.cubicTo(c2x, c2y, c1x, c1y, ox, oy);
- } else {
- path.cubicTo(c1x, c1y, c2x, c2y, ix, iy);
- }
- }
-
- private static void arc(Path path, float cx, float cy, float radius, float from, float to) {
- RectF oval = new RectF(cx - radius, cy - radius, cx + radius, cy + radius);
- path.arcTo(oval, from, to - from);
- }
-
- private static float polarX(float cx, float radius, float deg) {
- return cx + (float) (Math.cos(Math.toRadians(deg)) * radius);
- }
-
- private static float polarY(float cy, float radius, float deg) {
- return cy + (float) (Math.sin(Math.toRadians(deg)) * radius);
- }
-
- private void drawHub(Canvas canvas, float cx, float cy, float radius, int alpha) {
- // R7: the hub is a squircle-headed disc; the head fills 40dp of it.
- hubPaint.setStyle(Paint.Style.FILL);
- hubPaint.setColor(UiTokens.withAlpha(UiTokens.WHEEL_HUB_FILL,
- (int) (((UiTokens.WHEEL_HUB_FILL >>> 24) & 0xFF) * (alpha / 255f))));
- float headRadius = Math.min(radius, RadialWheelMotion.HUB_HEAD_DP * density / 2f);
- canvas.drawCircle(cx, cy, radius, hubPaint);
- drawHead(canvas, cx, cy, headRadius);
- // R7: the active destination's name under the head, at 10sp and 60% white.
- String active = hubLabel();
- if (active != null && !active.isEmpty()) {
- labelPaint.setTextSize(RadialWheelMotion.HUB_LABEL_SP * density);
- labelPaint.setColor(UiTokens.withAlpha(0xFFFFFFFF,
- (int) (RadialWheelMotion.HUB_LABEL_ALPHA * 255 * (alpha / 255f))));
- canvas.drawText(RadialWheelMotion.ellipsise(active), cx, cy + radius + 14f * density,
- labelPaint);
- labelPaint.setTextSize(RadialWheelMotion.LABEL_SP * density);
- }
- }
-
- /** The active destination's label for the hub, or null when nothing is highlighted (R7). */
- private String hubLabel() {
- if (activeProfile != null) {
- WheelEntry.Profile profile = currentProfile();
- if (profile == null) return null;
- if (highlightedEntry >= 0 && highlightedEntry < profile.safeEntries().size()) {
- return profile.safeEntries().get(highlightedEntry).label;
- }
- return profile.name;
- }
- return RadialWheelLayout.isDestination(highlighted) ? sectorLabel(highlighted) : null;
- }
-
- private void drawSectorContent(Canvas canvas, float cx, float cy, float radius, int alpha) {
- float iconSize = RadialWheelMotion.ICON_DP * density;
- float labelSize = RadialWheelMotion.LABEL_SP * density;
- labelPaint.setTextSize(labelSize);
- // R6: one line, medium weight, ellipsised at the character budget.
- labelPaint.setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.NORMAL));
- // Centre the icon+label block vertically in the ring band: the block is iconSize tall plus a
- // small gap plus the label, so its centre is the band radius and both halves sit around it.
- float gap = 4f * density;
- float blockHeight = iconSize + gap + labelSize;
- float blockTop = -blockHeight / 2f;
- float iconCenterY = blockTop + iconSize / 2f;
- float labelBaselineY = blockTop + iconSize + gap + labelSize * 0.8f;
- for (Sector s : SECTORS) {
- double a = Math.toRadians(RadialWheelLayout.sectorCenterDegrees(s));
- float px = cx + (float) (Math.cos(a) * radius);
- float py = cy + (float) (Math.sin(a) * radius);
- UiIcons.draw(canvas, iconPaint, iconFor(s), px, py + iconCenterY, iconSize,
- UiTokens.withAlpha(UiTokens.TEXT_PRIMARY, alpha), density);
- String label = RadialWheelMotion.ellipsise(sectorLabel(s));
- // R10: high contrast outlines the label so it stays readable on a bright frame without
- // thickening the whole sheet.
- if (material.outlinedLabels) {
- Paint outline = glassRenderer.labelOutlinePaint(labelPaint, 0xFF000000, density);
- outline.setTextAlign(Paint.Align.CENTER);
- outline.setTextSize(labelSize);
- canvas.drawText(label, px, py + labelBaselineY, outline);
- }
- labelPaint.setColor(UiTokens.withAlpha(UiTokens.TEXT_PRIMARY, alpha));
- canvas.drawText(label, px, py + labelBaselineY, labelPaint);
- }
- }
-
- private static UiIcons.Icon iconFor(Sector s) {
- switch (s) {
- case MODS: return UiIcons.Icon.CUBE;
- case HUD: return UiIcons.Icon.PENCIL;
- case CLOSET: return UiIcons.Icon.HANGER;
- case SETTINGS: return UiIcons.Icon.GEAR;
- default: return UiIcons.Icon.CUBE;
- }
- }
-
- private void drawHead(Canvas canvas, float cx, float cy, float radius) {
- if (headSkin.hasScene()) {
- if (drawHead3D(canvas, cx, cy, radius)) return;
- }
- if (!headSkin.hasFlatHead()) {
- // No skin available: draw a neutral accent disc rather than a glyph or emoji.
- hubPaint.setColor(UiTokens.ACCENT_900);
- canvas.drawCircle(cx, cy, radius * 0.8f, hubPaint);
- return;
- }
- dstHead.set(cx - radius * 0.72f, cy - radius * 0.72f,
- cx + radius * 0.72f, cy + radius * 0.72f);
- headClip.reset();
- headClip.addCircle(cx, cy, radius * 0.9f, Path.Direction.CW);
- int save = canvas.save();
- canvas.clipPath(headClip);
- headPaint.setFilterBitmap(false);
- headSkin.drawFlat(canvas, dstHead, headPaint);
- canvas.restoreToCount(save);
- }
-
- /** Renders the 3D head turntable into the hub; false when it cannot allocate a bitmap. */
- private boolean drawHead3D(Canvas canvas, float cx, float cy, float radius) {
- int size = Math.max(24, (int) (radius * 1.7f));
- if (headBitmap == null || headBitmap.getWidth() != size) {
- try {
- headBitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888);
- } catch (Throwable t) {
- headBitmap = null;
- return false;
- }
- }
- headRaster.prepare(size, size, 0x00000000);
- // The scene holds ONLY the head box (see RadialHeadSkin), so the camera targets the head's
- // own centre (model y = 28 for the 8x8x8 head whose bottom sits at 24) rather than the chest
- // of a bust. A tight margin then fills the disc with the face instead of leaving the head
- // adrift in a large empty circle.
- SkinModel.Camera cam = new SkinModel.Camera(headYawDeg, headPitchDeg,
- SkinModel.CAMERA_DISTANCE, 0f, 28f, 0f);
- PreviewScene.Fit fit = headSkin.render(cam, headRaster, size);
- headBitmap.setPixels(headRaster.pixels(), 0, size, 0, 0, size, size);
- headClip.reset();
- headClip.addCircle(cx, cy, radius * 0.94f, Path.Direction.CW);
- int save = canvas.save();
- canvas.clipPath(headClip);
- headPaint.setFilterBitmap(true);
- dstHead.set(cx - radius * 0.94f, cy - radius * 0.94f,
- cx + radius * 0.94f, cy + radius * 0.94f);
- canvas.drawBitmap(headBitmap, null, dstHead, headPaint);
- canvas.restoreToCount(save);
- return true;
- }
-
- /**
- * Draws both module columns.
- *
- *
Left: the quick options the player can turn on, drawn left-anchored. Right: the modules that
- * are actually enabled, right-anchored. Both are compact columns (one row pitch apart), so they
- * read as grouped lists rather than the old widely-spaced clusters. A row's status dot shows
- * whether its module is currently on.
- */
- private void drawEnabledList(Canvas canvas, int w, int h, int alpha) {
- chipTextPaint.setTextSize(UiChip.LABEL_SP * density);
- if (!quickChips.isEmpty()) {
- float[][] centers = RadialWheelLayout.moduleListCenters(
- w, h, quickChips.size(), density, false);
- for (int i = 0; i < quickChips.size() && i < centers.length; i++) {
- Chip chip = quickChips.get(i);
- float labelWidth = chipTextPaint.measureText(chip.label);
- boolean on = chipToggle != null && chipToggle.isChipOn(chip.moduleId);
- UiChip.draw(canvas, chipPaint, chipTextPaint, centers[i][0], centers[i][1],
- labelWidth, chip.label, UiTokens.withAlpha(accent, alpha), on, density,
- UiChip.Align.CENTER);
- }
- }
- if (!chips.isEmpty()) {
- float[][] centers = RadialWheelLayout.moduleListCenters(
- w, h, chips.size(), density, true);
- for (int i = 0; i < chips.size() && i < centers.length; i++) {
- Chip chip = chips.get(i);
- float labelWidth = chipTextPaint.measureText(chip.label);
- boolean on = chipToggle != null && chipToggle.isChipOn(chip.moduleId);
- UiChip.draw(canvas, chipPaint, chipTextPaint, centers[i][0], centers[i][1],
- labelWidth, chip.label, UiTokens.withAlpha(accent, alpha), on, density,
- UiChip.Align.RIGHT);
- }
- }
- }
-
- /**
- * The (list, row) hit under a point, or null.
- *
- *
Both columns are hit-tested so a tap selects whichever row is under it; the left column is
- * left-anchored, so its hit box hugs the chip's left edge.
- */
- int[] chipHit(float x, float y, int w, int h) {
- if (chipAtIn(quickChips, x, y, w, h, false) >= 0) {
- return new int[] {0, chipAtIn(quickChips, x, y, w, h, false)};
- }
- int right = chipAtIn(chips, x, y, w, h, true);
- return right >= 0 ? new int[] {1, right} : null;
- }
-
- private int chipAtIn(List list, float x, float y, int w, int h, boolean fromRight) {
- if (list.isEmpty()) return -1;
- float[][] centers = RadialWheelLayout.moduleListCenters(w, h, list.size(), density, fromRight);
- chipTextPaint.setTextSize(UiChip.LABEL_SP * density);
- for (int i = 0; i < list.size() && i < centers.length; i++) {
- float cw = UiChip.width(chipTextPaint.measureText(list.get(i).label), density);
- float ch = UiChip.HEIGHT_DP * density;
- float left = fromRight ? centers[i][0] - cw : centers[i][0] - cw / 2f;
- float right = fromRight ? centers[i][0] : centers[i][0] + cw / 2f;
- if (x >= left && x <= right && Math.abs(y - centers[i][1]) <= ch / 2f) {
- return i;
- }
- }
- return -1;
- }
-
- // --- Accessibility surface -------------------------------------------------------
- //
- // The bridge in RadialWheelAccessibilityHelper reads these; keeping them here means the
- // helper needs no reflection and the wheel's own fields stay private.
-
- /** The number of virtual slots (ring destinations or profile entries). */
- int a11ySlotCount() {
- if (activeProfile != null) {
- WheelEntry.Profile profile = currentProfile();
- return profile == null ? 0 : profile.safeEntries().size();
- }
- return SECTORS.length;
- }
-
- /** The spoken label of a ring slot. */
- String a11ySlotLabel(int index) {
- if (activeProfile != null) {
- WheelEntry.Profile profile = currentProfile();
- if (profile == null) return null;
- List entries = profile.safeEntries();
- return index >= 0 && index < entries.size() ? entries.get(index).label : null;
- }
- if (index < 0 || index >= SECTORS.length) return null;
- return sectorLabel(SECTORS[index]);
- }
-
- /** The centre of a ring slot's touch target, in view coordinates. */
- float[] a11ySlotCenter(int index) {
- float outer = outerRadius();
- return RadialWheelLayout.slotCenter(index, Math.max(1, a11ySlotCount()),
- centerX(), centerY(), ringInnerRadius(), outer);
- }
-
- /** Whether a ring slot is the current selection. */
- boolean a11ySlotSelected(int index) {
- if (activeProfile != null) return index == highlightedEntry;
- return index >= 0 && index < SECTORS.length && SECTORS[index] == highlighted;
- }
-
- /** Activates a ring slot exactly as a tap or a stick commit would. */
- void a11yActivateSlot(int index) {
- if (activeProfile != null) {
- activateProfileEntry(index);
- return;
- }
- if (index < 0 || index >= SECTORS.length) return;
- Sector s = SECTORS[index];
- if (!RadialWheelLayout.isDestination(s)) return;
- applyHaptic(RadialWheelMotion.hapticFor(false, false, true));
- if (listener != null) listener.onSector(s);
- }
-
- /** The module chips shown on the wheel, left column first then the right column. */
- List a11yChips() {
- List all = new ArrayList<>(quickChips.size() + chips.size());
- all.addAll(quickChips);
- all.addAll(chips);
- return all;
- }
-
- /**
- * The combined-index chip (left column first, then right) under a point, or -1.
- *
- * {@link #a11yChips} concatenates the two columns, so the bridge needs the same numbering;
- * {@link #chipHit} numbers each column separately, and translating here keeps that knowledge in
- * the view rather than in the accessibility helper.
- */
- int a11yChipIndexAt(float x, float y) {
- int[] hit = chipHit(x, y, getWidth(), getHeight());
- if (hit == null) return -1;
- return hit[0] == 0 ? hit[1] : quickChips.size() + hit[1];
- }
-
- /** The bounds of a combined-index chip, in view coordinates. */
- android.graphics.Rect a11yChipBounds(int combinedIndex) {
- android.graphics.Rect out = new android.graphics.Rect();
- int quick = quickChips.size();
- List list = combinedIndex < quick ? quickChips : chips;
- boolean fromRight = combinedIndex >= quick;
- int index = fromRight ? combinedIndex - quick : combinedIndex;
- if (index < 0 || index >= list.size()) return out;
- float[][] centers = RadialWheelLayout.moduleListCenters(
- getWidth(), getHeight(), list.size(), density, fromRight);
- if (index >= centers.length) return out;
- chipTextPaint.setTextSize(UiChip.LABEL_SP * density);
- float cw = UiChip.width(chipTextPaint.measureText(list.get(index).label), density);
- float ch = UiChip.HEIGHT_DP * density;
- float left = fromRight ? centers[index][0] - cw : centers[index][0] - cw / 2f;
- float right = fromRight ? centers[index][0] : centers[index][0] + cw / 2f;
- out.set(Math.round(left), Math.round(centers[index][1] - ch / 2f),
- Math.round(right), Math.round(centers[index][1] + ch / 2f));
- return out;
- }
-
- /** Whether a chip's module is currently enabled. */
- boolean a11yChipOn(Chip chip) {
- return chipToggle != null && chip != null && chipToggle.isChipOn(chip.moduleId);
- }
-
- /** Toggles a chip's module, the same path a tap takes. */
- void a11yActivateChip(Chip chip) {
- if (chip == null || chipToggle == null) return;
- chipToggle.onChipToggled(chip.moduleId);
- applyHaptic(RadialWheelMotion.hapticFor(false, false, true));
- rebuildEnabledList();
- invalidate();
- }
-
- /** The right-column index under a point, or -1 (kept for the controller cursor's highlight). */
- int chipAt(float x, float y, int w, int h) {
- return chipAtIn(chips, x, y, w, h, true);
- }
-
- private static String sectorLabel(Sector s) {
- switch (s) {
- case MODS: return "MODS";
- case CLOSET: return "CUSTOMIZE";
- case SETTINGS: return "SETTINGS";
- case HUD: return "HUD";
- default: return "";
- }
- }
-
- // --- Controller cursor ------------------------------------------------------------------
-
- /**
- * Enables controller-cursor mode: a cursor appears at the hub and the stick drives it.
- * Called when the wheel is opened by the controller bind.
- *
- * The wheel is enabled from a post on the overlay window, which can run before the view has
- * been measured. Creating the cursor with a 1x1 viewport there clamped it to a single pixel and
- * it could never move — the "controller cursor does not work" report. The viewport is therefore
- * kept in step with the real size in {@link #onSizeChanged}, and the cursor is centred the first
- * time a usable size arrives rather than being pinned at the top-left corner.
- */
- public void enableControllerCursor() {
- cursorVisible = true;
- applyCursorViewport(true);
- invalidate();
- }
-
- /**
- * Creates or updates the cursor for the current view size.
- *
- * @param centreNow true to recentre the cursor (a fresh open); false to only fix the viewport
- * (a rotation/resize must not yank the cursor back to the middle)
- */
- private void applyCursorViewport(boolean centreNow) {
- int w = getWidth();
- int h = getHeight();
- if (w <= 0 || h <= 0) return; // Not laid out yet; onSizeChanged will run.
- if (cursor == null) {
- cursor = new CursorController(w, h, density);
- } else {
- cursor.setViewport(w, h);
- if (centreNow) cursor.centre();
- }
- }
-
- @Override
- protected void onSizeChanged(int w, int h, int oldw, int oldh) {
- super.onSizeChanged(w, h, oldw, oldh);
- // The first real size arrives after the overlay's post; without this the cursor kept the
- // 1x1 viewport it was created with and never moved.
- if (cursor != null) {
- cursor.setViewport(Math.max(1, w), Math.max(1, h));
- if (oldw == 0 || oldh == 0) cursor.centre();
- }
- invalidate();
- }
-
- /** True when the view is laid out far enough for the cursor to move meaningfully. */
- private boolean cursorViewportUsable() {
- return getWidth() > 1 && getHeight() > 1;
- }
-
- /** Hides the cursor (a touch, or the wheel closing). */
- public void disableControllerCursor() {
- cursorVisible = false;
- cursorChip = -1;
- invalidate();
- }
-
- public boolean isCursorVisible() {
- return cursorVisible && cursor != null;
- }
-
- /**
- * Feeds one frame of controller input.
- *
- * @return true when the wheel consumed it (so the caller must not forward it to the game)
- */
- public boolean handleControllerStick(float rawX, float rawY, float dt, boolean precision,
- boolean fast, float deadZone, float curveExponent) {
- // A stick frame can arrive before the overlay's post enabled the cursor (or before layout),
- // so create it here too rather than returning early — the earlier early-return is part of
- // why the cursor never appeared.
- if (cursor == null) {
- applyCursorViewport(true);
- if (cursor == null) return false; // Still not laid out; nothing sensible to move.
- }
- cursorVisible = true;
- float[] shaped = new float[2];
- CursorController.shape(rawX, rawY, deadZone, curveExponent, shaped);
- // Android's stick Y is negative when pushed up, which matches screen Y growing down (an up
- // push must reduce the cursor's y). Pass the shaped axes through unchanged.
- cursor.setMagnetTarget(false, 0f, 0f);
- cursor.update(shaped[0], shaped[1], dt, precision, fast,
- CursorController.DEFAULT_MAX_SPEED_DP);
- // The head looks at the cursor, not at the stick vector: the hub stays a still portrait
- // until the player moves the cursor, then the head turns to face where the cursor now is.
- // The same rule runs for touch (see onTouchEvent), so both inputs turn it consistently.
- updateHeadLook(cursor.x(), cursor.y(), dt);
- // Magnetism: ease toward the sector icon or chip under the cursor.
- applyCursorMagnetism();
- updateCursorHighlight();
- invalidate();
- return true;
- }
-
- /**
- * Points the hub head at the cursor's current position, eased by one frame.
- *
- *
Called from every input path (the right stick and touch), so the head is still until the
- * player moves the cursor, and then turns to face where the cursor is — it never keeps spinning
- * after the cursor stops. Yaw/pitch come from {@link RadialHeadTurn#targetLook} on the cursor's
- * offset from the hub centre, so the exact position (not a velocity) drives the look.
- *
- * @param cursorX cursor/screen x within the view, pixels
- * @param cursorY cursor/screen y within the view, pixels
- * @param dtSeconds elapsed time for this input frame (clamped by the caller)
- */
- void updateHeadLook(float cursorX, float cursorY, float dtSeconds) {
- float cx = centerX();
- float cy = centerY();
- float maxH = hubRadius() * 0.9f;
- float maxV = hubRadius() * 0.9f;
- float[] target = RadialHeadTurn.targetLook(cursorX - cx, cursorY - cy, maxH, maxV);
- float[] eased = RadialHeadTurn.stepTowards(
- headYawDeg, headPitchDeg, target[0], target[1], dtSeconds);
- headYawDeg = eased[0];
- headPitchDeg = eased[1];
- invalidate();
- }
-
- private void applyCursorMagnetism() {
- float cx = centerX();
- float cy = centerY();
- float outer = outerRadius();
- float inner = RadialWheelLayout.ringInnerRadius(outer);
- float radius = (inner + outer) / 2f;
- if (activeProfile != null) {
- int entry = profileEntryAt(cursor.x(), cursor.y());
- if (entry >= 0) {
- int count = WheelLayout.clampSectorCount(currentProfile().safeEntries().size());
- double a = Math.toRadians(WheelLayout.sectorCenterDegrees(entry, count));
- cursor.setMagnetTarget(true, cx + (float) (Math.cos(a) * radius),
- cy + (float) (Math.sin(a) * radius));
- } else {
- cursor.setMagnetTarget(false, 0f, 0f);
- }
- return;
- }
- Sector s = RadialWheelLayout.sectorAtLinear(
- cursor.x() - cx, cursor.y() - cy, inner, outer, RadialWheelLayout.GAP_DP * density);
- if (RadialWheelLayout.isDestination(s)) {
- double a = Math.toRadians(RadialWheelLayout.sectorCenterDegrees(s));
- float ix = cx + (float) (Math.cos(a) * radius);
- float iy = cy + (float) (Math.sin(a) * radius);
- cursor.setMagnetTarget(true, ix, iy);
- } else {
- cursor.setMagnetTarget(false, 0f, 0f);
- }
- }
-
- private void updateCursorHighlight() {
- float cx = centerX();
- float cy = centerY();
- float outer = outerRadius();
- float inner = RadialWheelLayout.ringInnerRadius(outer);
- if (activeProfile != null) {
- setHighlightedEntry(profileEntryAt(cursor.x(), cursor.y()));
- } else {
- Sector s = RadialWheelLayout.sectorAtLinear(
- cursor.x() - cx, cursor.y() - cy, inner, outer,
- RadialWheelLayout.GAP_DP * density);
- setHighlight(s);
- }
- int[] hit = chipHit(cursor.x(), cursor.y(), getWidth(), getHeight());
- cursorChip = hit != null && hit[0] == 1 ? hit[1] : -1;
- }
-
- /**
- * Whether anything is highlighted that a release could commit.
- *
- *
Used by the hold-to-open rule: a release with nothing highlighted dismisses the wheel
- * rather than activating whatever happens to be under a stationary cursor.
- */
- public boolean hasSelection() {
- if (cursorChip >= 0) return true;
- if (activeProfile != null) return highlightedEntry >= 0;
- return RadialWheelLayout.isDestination(highlighted);
- }
-
- /**
- * The SELECT button: activates the chip under the cursor if there is one, else the sector.
- *
- * @return true when something was activated
- */
- public boolean controllerSelect() {
- if (cursor == null) return false;
- int[] hit = chipHit(cursor.x(), cursor.y(), getWidth(), getHeight());
- if (hit != null) {
- List list = hit[0] == 0 ? quickChips : chips;
- int index = hit[1];
- if (index >= 0 && index < list.size()) {
- if (chipToggle != null) chipToggle.onChipToggled(list.get(index).moduleId);
- applyHaptic(RadialWheelMotion.hapticFor(false, false, true));
- // The right column is the enabled set, so a toggle must rebuild it immediately.
- rebuildEnabledList();
- cursorChip = -1;
- invalidate();
- return true;
- }
- }
- if (activeProfile != null) {
- int entry = profileEntryAt(cursor.x(), cursor.y());
- if (entry >= 0) {
- activateProfileEntry(entry);
- return true;
- }
- return false;
- }
- float cx = centerX();
- float cy = centerY();
- float outer = outerRadius();
- float inner = RadialWheelLayout.ringInnerRadius(outer);
- Sector s = RadialWheelLayout.sectorAtLinear(
- cursor.x() - cx, cursor.y() - cy, inner, outer, RadialWheelLayout.GAP_DP * density);
- if (listener != null && RadialWheelLayout.isDestination(s)) {
- // R5: a committed selection gets the medium pulse.
- applyHaptic(RadialWheelMotion.hapticFor(false, false, true));
- listener.onSector(s);
- return true;
- }
- return false;
- }
-
- /**
- * The BACK button.
- *
- * In profile mode this backs out of a sub-wheel first, and only dismisses at the root — the
- * same "Back leaves the current level" rule the launcher's own screens use.
- */
- public boolean controllerBack() {
- if (activeProfile != null && backOut()) {
- applyHaptic(RadialWheelMotion.hapticFor(false, true, false));
- return true;
- }
- if (entryListener != null && activeProfile != null) {
- entryListener.onBack();
- return true;
- }
- if (listener != null) {
- listener.onDismiss();
- return true;
- }
- return false;
- }
-
- /** The PIN button: pin/unpin the hovered chip's module. Returns true when a chip was hit. */
- public boolean controllerPinHoveredChip() {
- int[] hit = cursor == null ? null : chipHit(cursor.x(), cursor.y(), getWidth(), getHeight());
- if (hit != null) {
- List list = hit[0] == 0 ? quickChips : chips;
- int index = hit[1];
- if (index >= 0 && index < list.size()) {
- if (chipToggle != null) chipToggle.onChipToggled(list.get(index).moduleId);
- applyHaptic(RadialWheelMotion.hapticFor(false, false, true));
- rebuildEnabledList();
- cursorChip = -1;
- invalidate();
- return true;
- }
- }
- return false;
- }
-
- /** Moves the cursor to a sector's icon (D-pad focus mode). Hides the free cursor. */
- public void focusSector(Sector s) {
- if (!RadialWheelLayout.isDestination(s)) return;
- float cx = centerX();
- float cy = centerY();
- float outer = outerRadius();
- float inner = RadialWheelLayout.ringInnerRadius(outer);
- float radius = (inner + outer) / 2f;
- double a = Math.toRadians(RadialWheelLayout.sectorCenterDegrees(s));
- if (cursor == null) {
- cursor = new CursorController(Math.max(1, getWidth()), Math.max(1, getHeight()), density);
- }
- cursor.setPosition(cx + (float) (Math.cos(a) * radius),
- cy + (float) (Math.sin(a) * radius));
- cursorVisible = false;
- setHighlight(s);
- invalidate();
- }
-
- /** The D-pad left/right: step through the sectors (or the open profile's entries). */
- public void focusNextSector(int direction) {
- if (activeProfile != null) {
- WheelEntry.Profile profile = currentProfile();
- if (profile == null || profile.safeEntries().isEmpty()) return;
- int count = WheelLayout.clampSectorCount(profile.safeEntries().size());
- int current = highlightedEntry < 0 ? 0 : highlightedEntry;
- // Routed through the shared setter so the step ticks once, and only when the slot
- // actually moved (a one-entry sub-wheel would otherwise buzz on every press).
- setHighlightedEntry(((current + direction) % count + count) % count);
- return;
- }
- Sector[] order = {Sector.MODS, Sector.CLOSET, Sector.SETTINGS, Sector.HUD};
- Sector current = highlighted;
- int idx = 0;
- for (int i = 0; i < order.length; i++) if (order[i] == current) idx = i;
- int next = ((idx + direction) % order.length + order.length) % order.length;
- focusSector(order[next]);
- applyHaptic(RadialWheelMotion.hapticFor(false, true, false));
- }
-
- /** The LB/RB: cycle the chips, selecting the module under the cursor. */
- public void cycleChip(int direction) {
- if (chips.isEmpty()) return;
- int next = cursorChip < 0 ? 0 : (cursorChip + direction + chips.size()) % chips.size();
- cursorChip = next;
- invalidate();
- }
-
- // --- Touch ------------------------------------------------------------------------------
-
- @Override
- public boolean onTouchEvent(MotionEvent event) {
- // A touch hides the controller cursor: the two input methods never fight.
- if (cursorVisible) {
- disableControllerCursor();
- }
- float cx = centerX();
- float cy = centerY();
- float outer = outerRadius();
- float inner = ringInnerRadius();
- float gapPx = RadialWheelLayout.GAP_DP * density;
-
- switch (event.getActionMasked()) {
- case MotionEvent.ACTION_DOWN:
- if (handleChipTap(event.getX(), event.getY(), getWidth(), getHeight())) {
- return true;
- }
- setCancelArmed(false);
- startFlickTracker(event);
- if (activeProfile != null) {
- setHighlightedEntry(profileEntryAt(event.getX(), event.getY()));
- } else {
- setHighlight(RadialWheelLayout.sectorAtLinear(
- event.getX() - cx, event.getY() - cy, inner, outer, gapPx));
- }
- // Touch turns the head the same way the stick does: look at the pointer.
- updateHeadLook(event.getX(), event.getY(), 1f / 60f);
- return true;
- case MotionEvent.ACTION_MOVE:
- // R11: a drag well past the outer radius arms the cancel gesture; the ring dims and
- // shrinks so the player can see the release will dismiss.
- float dist = (float) Math.hypot(event.getX() - cx, event.getY() - cy);
- setCancelArmed(RadialWheelMotion.isCancelArmed(dist, outer, density));
- if (cancelArmed) {
- updateHeadLook(event.getX(), event.getY(), 1f / 60f);
- return true;
- }
- if (flickTracker != null) flickTracker.addMovement(event);
- if (activeProfile != null) {
- setHighlightedEntry(profileEntryAt(event.getX(), event.getY()));
- } else {
- setHighlight(RadialWheelLayout.sectorAtLinear(
- event.getX() - cx, event.getY() - cy, inner, outer, gapPx));
- }
- updateHeadLook(event.getX(), event.getY(), 1f / 60f);
- return true;
- case MotionEvent.ACTION_UP:
- if (cancelArmed) {
- stopFlickTracker();
- setCancelArmed(false);
- highlighted = Sector.NONE;
- highlightedEntry = -1;
- animateHover(null);
- invalidate();
- if (listener != null) listener.onDismiss();
- return true;
- }
- // A flick toward a slice commits it even if the pointer stopped short of the ring;
- // a slow release falls through to the sector under the pointer, which is the click
- // path. Profile wheels resolve by the entry under the pointer, so the flick only
- // applies to the fixed wheel, where the slices are the four fixed destinations.
- Sector flicked = activeProfile == null ? takeFlickSector(outer, gapPx) : Sector.NONE;
- if (activeProfile != null) {
- int hit = profileEntryAt(event.getX(), event.getY());
- highlightedEntry = -1;
- invalidate();
- if (hit >= 0) {
- activateProfileEntry(hit);
- } else if (listener != null) {
- listener.onDismiss();
- }
- return true;
- }
- Sector s = flicked != Sector.NONE ? flicked : RadialWheelLayout.sectorAtLinear(
- event.getX() - cx, event.getY() - cy, inner, outer, gapPx);
- highlighted = Sector.NONE;
- animateHover(null);
- invalidate();
- if (listener != null) {
- if (RadialWheelLayout.isDestination(s)) {
- // R5: a committed selection gets the medium pulse, never on open.
- applyHaptic(RadialWheelMotion.hapticFor(false, false, true));
- listener.onSector(s);
- } else {
- listener.onDismiss();
- }
- }
- return true;
- case MotionEvent.ACTION_CANCEL:
- stopFlickTracker();
- setCancelArmed(false);
- highlighted = Sector.NONE;
- highlightedEntry = -1;
- invalidate();
- return true;
- default:
- return super.onTouchEvent(event);
- }
- }
-
- /** Starts tracking a gesture's velocity for the flick test. */
- private void startFlickTracker(MotionEvent event) {
- if (flickTracker == null) {
- flickTracker = android.view.VelocityTracker.obtain();
- } else {
- flickTracker.clear();
- }
- flickTracker.addMovement(event);
- }
-
- /** Stops and recycles the flick tracker. */
- private void stopFlickTracker() {
- if (flickTracker != null) {
- flickTracker.recycle();
- flickTracker = null;
- }
- }
-
- /**
- * The sector a release flick selects, or {@link Sector#NONE} for a slow release.
- *
- * The velocity is read before the tracker is recycled so the rule sees the real release
- * speed. Everything else is {@link RadialWheelLayout#flickSector}, which owns the threshold and
- * the gap so the flick and the pointer hit test cannot disagree.
- */
- private Sector takeFlickSector(float outer, float gapPx) {
- if (flickTracker == null) return Sector.NONE;
- flickTracker.computeCurrentVelocity(1000);
- float vx = flickTracker.getXVelocity();
- float vy = flickTracker.getYVelocity();
- Sector sector = RadialWheelLayout.flickSector(vx, vy, outer, gapPx);
- stopFlickTracker();
- return sector;
- }
-
- /** The profile-entry index under a point, or -1. */
- private int profileEntryAt(float x, float y) {
- WheelEntry.Profile profile = currentProfile();
- if (profile == null) return -1;
- List entries = profile.safeEntries();
- if (entries.isEmpty()) return -1;
- float outer = outerRadius();
- float inner = ringInnerRadius();
- int count = usableSectorCount(entries, outer);
- return WheelLayout.sectorAt(x - centerX(), y - centerY(), inner, outer,
- WheelLayout.GAP_DP * density, count);
- }
-
- /**
- * The sector count the wheel will actually draw and hit-test (R1).
- *
- * Delegates to {@link RadialWheelMotion#segmentsFor}, which owns the rule, so the draw path
- * and the hit test cannot compute different counts. This is the view's only involvement in the
- * decision: it supplies the entry count and the measured radius.
- */
- private int usableSectorCount(List entries, float outerRadius) {
- return RadialWheelMotion.segmentsFor(entries == null ? 0 : entries.size(),
- outerRadius, density);
- }
-
- /**
- * Performs a profile entry's action.
- *
- * A sub-wheel is opened in place (the wheel is navigation, not a destination); anything else
- * is handed to the {@link EntryListener}, which owns the launcher-side routing. A sub-wheel
- * entry whose target no longer resolves is refused rather than silently doing nothing, so the
- * caller can dismiss.
- */
- private void activateProfileEntry(int index) {
- WheelEntry.Profile profile = currentProfile();
- if (profile == null) return;
- List entries = profile.safeEntries();
- if (index < 0 || index >= entries.size()) return;
- WheelEntry entry = entries.get(index);
- if (entry.isSubWheel() && openSubWheel(entry.target)) {
- applyHaptic(RadialWheelMotion.hapticFor(false, true, false));
- return;
- }
- applyHaptic(RadialWheelMotion.hapticFor(false, false, true));
- if (entryListener != null) entryListener.onEntry(entry);
- }
-
- /** Toggles the module row under the pointer, if any; returns true when a row was hit. */
- private boolean handleChipTap(float x, float y, int w, int h) {
- int[] hit = chipHit(x, y, w, h);
- if (hit == null) return false;
- List list = hit[0] == 0 ? quickChips : chips;
- int index = hit[1];
- if (index < 0 || index >= list.size()) return false;
- if (chipToggle != null) chipToggle.onChipToggled(list.get(index).moduleId);
- applyHaptic(RadialWheelMotion.hapticFor(false, false, true));
- // The right column reflects what is enabled, so a toggle (either direction) must rebuild it.
- rebuildEnabledList();
- invalidate();
- return true;
- }
-
- private void setHighlight(Sector sector) {
- if (sector == highlighted) return;
- boolean wasDestination = RadialWheelLayout.isDestination(highlighted);
- highlighted = sector;
- if (RadialWheelLayout.isDestination(sector) && !wasDestination) {
- // R5: a light tick when the pointer crosses into a new segment.
- applyHaptic(RadialWheelMotion.hapticFor(false, true, false));
- }
- animateHover(sector);
- invalidate();
- }
-
- /**
- * Moves the profile-entry highlight, ticking when it lands on a different slot.
- *
- * A profile wheel has no sectors to cross, so the fixed wheel's {@link #setHighlight} tick
- * never fires on it: without this the wheel is silent while the player steps through the
- * entries, which is exactly where feedback matters most because the slots are small. The
- * assignment sites all route through here so the tick cannot be forgotten on one path.
- */
- private void setHighlightedEntry(int index) {
- if (index == highlightedEntry) return;
- boolean hadSlot = highlightedEntry >= 0;
- highlightedEntry = index;
- if (index >= 0 && hadSlot) {
- applyHaptic(RadialWheelMotion.hapticFor(false, true, false));
- }
- invalidate();
- }
-
- /**
- * Applies the wheel's haptic vocabulary (R5).
- *
- *
The mapping from the pure {@link RadialWheelMotion.Haptic} to the platform constant lives
- * here, so the "which signal" decision is testable and this is the only place that touches
- * {@code HapticFeedbackConstants}.
- */
- private void applyHaptic(RadialWheelMotion.Haptic haptic) {
- switch (haptic) {
- case COMMIT:
- performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
- break;
- case TICK:
- performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK);
- break;
- case NONE:
- default:
- break;
- }
- }
-
- /**
- * Applies a variable-font weight axis to a paint (R6).
- *
- *
{@code Paint#setFontVariationSettings} is API 26, below this project's minSdk, so it needs
- * no guard; the {@code try} is for a device whose default font has no {@code wght} axis, where
- * the call is a no-op the framework may still reject. A missing axis must not cost the label.
- */
- private static void applyLabelWeight(Paint paint, int weight) {
- if (paint == null) return;
- try {
- paint.setFontVariationSettings("'wght' " + weight);
- } catch (Throwable ignored) {
- AppLog.warnOnce("CaramelClient", "RadialMenuView:1897", "Ignored a failure at RadialMenuView:1897", ignored);
- }
- }
-
- /** Arms or clears the cancel gesture, dimming and shrinking the ring while armed (R11). */
- private void setCancelArmed(boolean armed) {
- if (cancelArmed == armed) return;
- cancelArmed = armed;
- cancelOpacity = RadialWheelMotion.cancelOpacity(armed);
- cancelScale = RadialWheelMotion.cancelScale(armed);
- sectorPathsValid = false;
- profilePathsValid = false;
- invalidate();
- }
-
- /** True while a drag past the outer radius has armed the cancel (R11). */
- public boolean isCancelArmed() {
- return cancelArmed;
- }
-
- /** Scales the hovered sector to 1.04 over 80ms. */
- private void animateHover(Sector sector) {
- int idx = indexOf(sector);
- if (hoverAnimator != null) hoverAnimator.cancel();
- hoverAnimator = ValueAnimator.ofFloat(0f, 1f);
- hoverAnimator.setDuration(UiTokens.MOTION_PRESS_MS);
- hoverAnimator.setInterpolator(FAST_OUT_SLOW_IN);
- final int target = idx;
- hoverAnimator.addUpdateListener(a -> {
- float t = (float) a.getAnimatedValue();
- for (int i = 0; i < sectorScale.length; i++) {
- float goal = (i == target) ? 1.04f : 1f;
- sectorScale[i] = sectorScale[i] + (goal - sectorScale[i]) * t;
- }
- invalidate();
- });
- hoverAnimator.start();
- }
-
- private static int indexOf(Sector sector) {
- for (int i = 0; i < SECTORS.length; i++) if (SECTORS[i] == sector) return i;
- return -1;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelAccessibilityHelper.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelAccessibilityHelper.java
deleted file mode 100644
index a5892e03..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelAccessibilityHelper.java
+++ /dev/null
@@ -1,147 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.graphics.Rect;
-import android.os.Bundle;
-import android.view.View;
-
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
-import androidx.customview.widget.ExploreByTouchHelper;
-
-import java.util.List;
-
-/**
- * The radial wheel's screen-reader surface.
- *
- *
The wheel is a single custom {@link View} that paints its slots itself, so to the accessibility
- * framework it is one opaque rectangle: a screen reader can focus the whole wheel but cannot reach
- * any individual slot. This bridge publishes each slot as a virtual child node with a label, a
- * bounds rectangle and a click action, which is the only way a touch-exploring user can drive a
- * canvas-drawn control.
- *
- *
Every decision the framework consumes is derived from the view's own geometry
- * ({@link RadialMenuView#a11ySlotCenter}) and state ({@link RadialMenuView#a11ySlotSelected}), and
- * the activation path ({@link RadialMenuView#a11yActivateSlot}) is the same one a tap takes, so a
- * screen-reader selection cannot behave differently from a touch. The helper only translates.
- *
- *
Kept in its own class rather than inline in the 1,700-line view so the slot model is
- * reviewable on its own; it holds no state beyond the ids the framework hands it.
- */
-final class RadialWheelAccessibilityHelper extends ExploreByTouchHelper {
-
- /** The touch-target size of a virtual slot node, in dp. */
- private static final int NODE_SIZE_DP = 48;
- /** The first virtual id handed out to a chip; ring slots take 0..count-1. */
- private static final int CHIP_ID_BASE = 1000;
-
- private final RadialMenuView view;
-
- RadialWheelAccessibilityHelper(RadialMenuView view) {
- super(view);
- this.view = view;
- // The bridge is what makes the view focusable to the framework; it also routes hover and
- // key events through the virtual tree. The window carries FLAG_NOT_FOCUSABLE, so the view
- // itself never holds input focus, but accessibility touch exploration still reaches it via
- // the node provider — which is why the view must be marked important for accessibility.
- view.setFocusable(true);
- view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
- }
-
- private int density() {
- return Math.max(1, Math.round(view.getResources().getDisplayMetrics().density));
- }
-
- /** The virtual id for a chip node. */
- private int chipVirtualId(int index) {
- return CHIP_ID_BASE + index;
- }
-
- @Override
- protected int getVirtualViewAt(float x, float y) {
- int slot = view.a11ySlotCount();
- for (int i = 0; i < slot; i++) {
- float[] c = view.a11ySlotCenter(i);
- if (c == null) continue;
- if (Math.hypot(x - c[0], y - c[1]) <= NODE_SIZE_DP * density() / 2f) {
- return i;
- }
- }
- int chip = view.a11yChipIndexAt(x, y);
- if (chip >= 0) return chipVirtualId(chip);
- return HOST_ID;
- }
-
- @Override
- protected void getVisibleVirtualViews(List virtualViewIds) {
- int slot = view.a11ySlotCount();
- for (int i = 0; i < slot; i++) {
- virtualViewIds.add(i);
- }
- List chips = view.a11yChips();
- for (int i = 0; i < chips.size(); i++) {
- virtualViewIds.add(chipVirtualId(i));
- }
- }
-
- @Override
- protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node) {
- if (virtualViewId >= CHIP_ID_BASE) {
- populateChipNode(virtualViewId - CHIP_ID_BASE, node);
- return;
- }
- String label = view.a11ySlotLabel(virtualViewId);
- if (label == null) {
- // The framework requires a node to have text and bounds; a slot that no longer resolves
- // (a profile changed under the open wheel) is reported as an empty, non-focusable node
- // rather than throwing.
- node.setContentDescription("");
- node.setBoundsInParent(new Rect());
- return;
- }
- float[] c = view.a11ySlotCenter(virtualViewId);
- int half = Math.round(NODE_SIZE_DP * density() / 2f);
- Rect bounds = new Rect();
- if (c != null) {
- bounds.set(Math.round(c[0]) - half, Math.round(c[1]) - half,
- Math.round(c[0]) + half, Math.round(c[1]) + half);
- }
- node.setContentDescription(label);
- node.setBoundsInParent(bounds);
- node.setSelected(view.a11ySlotSelected(virtualViewId));
- node.setClickable(true);
- node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
- }
-
- private void populateChipNode(int index, AccessibilityNodeInfoCompat node) {
- List chips = view.a11yChips();
- if (index < 0 || index >= chips.size()) {
- node.setContentDescription("");
- node.setBoundsInParent(new Rect());
- return;
- }
- RadialMenuView.Chip chip = chips.get(index);
- // A chip is a toggle, so its state is part of the label: a screen reader announces "Zoom,
- // on" rather than just "Zoom", which is the difference between a usable toggle and a guess.
- String state = view.a11yChipOn(chip) ? "on" : "off";
- node.setContentDescription(chip.label + ", " + state);
- node.setClassName("android.widget.Switch");
- node.setCheckable(true);
- node.setChecked(view.a11yChipOn(chip));
- node.setBoundsInParent(view.a11yChipBounds(index));
- node.setClickable(true);
- node.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
- }
-
- @Override
- protected boolean onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments) {
- if (action != AccessibilityNodeInfoCompat.ACTION_CLICK) return false;
- if (virtualViewId >= CHIP_ID_BASE) {
- List chips = view.a11yChips();
- int index = virtualViewId - CHIP_ID_BASE;
- if (index < 0 || index >= chips.size()) return false;
- view.a11yActivateChip(chips.get(index));
- return true;
- }
- view.a11yActivateSlot(virtualViewId);
- return true;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelLayout.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelLayout.java
deleted file mode 100644
index 9d9f35da..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelLayout.java
+++ /dev/null
@@ -1,302 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * The geometry of the radial mod wheel, as pure arithmetic.
- *
- * The wheel is four curved sectors around a central hub. Which sector a touch is over is a
- * function of the pointer's position relative to the wheel's centre and radii, so it is computed
- * here and unit-tested, and {@link RadialMenuOverlay} only paints and dispatches. Keeping it pure is
- * what makes "the sector under the finger" testable without a device — the class of bug that only
- * shows up when a sector's boundary is a few degrees off is exactly what a screenshot cannot catch.
- *
- *
Screen space: x grows right, y grows down, so the top sector is around -90 degrees, the right
- * around 0, the bottom around +90 and the left around 180. A small gap around each boundary keeps a
- * boundary touch from being ambiguous.
- */
-public final class RadialWheelLayout {
-
- /** The four destinations, plus the centre hub and "outside the wheel". */
- public enum Sector {
- MODS,
- CLOSET,
- SETTINGS,
- HUD,
- /** The centre hub (the player's head). */
- CENTER,
- /** Inside the outer radius check failed, or a boundary gap, or inside the inner dead hub. */
- NONE,
- }
-
- /**
- * The annulus' outer edge, as a fraction of {@code min(screenW, screenH)} (R1: 46%).
- *
- *
An annulus band rather than a disc: the wheel's inner hole is where the hub sits and where
- * the centre is reserved for "cancel", so a full disc would make the middle a dead zone the
- * player keeps hitting by accident.
- */
- public static final float ANNULUS_OUTER_FRACTION = 0.46f;
- /** The annulus' inner edge, as a fraction of {@code min(screenW, screenH)} (R1: 30%). */
- public static final float ANNULUS_INNER_FRACTION = 0.30f;
-
- /** The wheel's outer radius, as a fraction of {@code min(screenW, screenH)}. */
- public static final float OUTER_FRACTION = ANNULUS_OUTER_FRACTION;
- /**
- * The ring's inner radius, as a fraction of the outer radius.
- *
- *
Derived from the two annulus fractions rather than written as its own literal: two literals
- * that must agree is how the band silently becomes the wrong width.
- */
- public static final float RING_INNER_FRACTION = ANNULUS_INNER_FRACTION / ANNULUS_OUTER_FRACTION;
- /** The hub (avatar disc) radius, as a fraction of the outer radius. */
- public static final float HUB_FRACTION = 0.46f;
- /** The constant linear gap between neighbouring sectors, in dp. */
- public static final float GAP_DP = 6f;
- /** The corner radius of a sector, in dp. */
- public static final float SECTOR_CORNER_DP = 12f;
-
- private RadialWheelLayout() {
- }
-
- /** The wheel's outer radius in px for a view. */
- public static float outerRadius(int w, int h) {
- return Math.min(w, h) * OUTER_FRACTION;
- }
-
- /** The ring's inner radius in px. */
- public static float ringInnerRadius(float outerRadius) {
- return outerRadius * RING_INNER_FRACTION;
- }
-
- /** The hub radius in px. */
- public static float hubRadius(float outerRadius) {
- return outerRadius * HUB_FRACTION;
- }
-
- /**
- * The minimum pointer speed, in px/s, that counts as a flick rather than a placement.
- *
- *
Below this a release is a click and the sector under the pointer wins; above it the
- * flick's own direction picks the sector, so a quick throw toward a slice commits it even if the
- * pointer stopped short of the ring. Set so a deliberate throw is a flick and a slow drag is not.
- */
- public static final float FLICK_MIN_SPEED_PX_S = 900f;
-
- /**
- * The sector a flick selects, from the release velocity.
- *
- *
Spec: "make a selection by flicking your mouse toward a segment or by clicking it
- * directly." A flick is resolved by the velocity's direction only: its magnitude must clear
- * {@link #FLICK_MIN_SPEED_PX_S}, and the direction must leave the hub so an ambiguous throw at
- * the centre does not pick a slice. The angular gap is applied the same way the pointer path
- * applies it, so a flick straight at a boundary reads as no selection rather than a coin flip.
- *
- * @param vx,vy release velocity in px/s (screen space, y down)
- * @param radius the ring radius used for the angular gap, so the flick and the pointer agree
- * @param gapPx the constant linear gap between sectors
- * @return the flicked sector, or {@link Sector#NONE} when the throw is too slow or too central
- */
- public static Sector flickSector(float vx, float vy, float radius, float gapPx) {
- float speed = (float) Math.hypot(vx, vy);
- if (!(speed >= FLICK_MIN_SPEED_PX_S)) return Sector.NONE;
- double deg = Math.toDegrees(Math.atan2(vy, vx));
- if (deg < -180.0) deg += 360.0;
- if (nearestBoundaryDistance(deg) < angularHalfGapDegrees(radius, gapPx)) return Sector.NONE;
- return quadrantFor(deg);
- }
-
- /**
- * The angular half-gap at a radius for a constant linear gap. A fixed linear gap is
- * parallel-sided; a fixed angular gap widens with radius (the cheap pie-chart look).
- */
- public static double angularHalfGapDegrees(float radius, float gapPx) {
- if (radius <= 1f) return 90.0;
- return Math.toDegrees((gapPx / 2.0) / radius);
- }
-
- /**
- * The sector under a pointer with a parallel-sided linear gap.
- *
- * @param dx,dy pointer minus the wheel centre
- * @param innerRadius the ring's inner edge; inside this is CENTER
- * @param outerRadius the ring's outer edge; outside this is NONE
- * @param gapPx the constant linear gap between sectors
- */
- public static Sector sectorAtLinear(float dx, float dy, float innerRadius, float outerRadius,
- float gapPx) {
- float distance = (float) Math.hypot(dx, dy);
- if (distance <= innerRadius) return Sector.CENTER;
- if (distance > outerRadius) return Sector.NONE;
- double deg = Math.toDegrees(Math.atan2(dy, dx));
- if (deg < -180.0) deg += 360.0;
- double halfGap = angularHalfGapDegrees(distance, gapPx);
- if (nearestBoundaryDistance(deg) < halfGap) return Sector.NONE;
- return quadrantFor(deg);
- }
-
- /**
- * The centre point of a ring slot's touch target, in view coordinates.
- *
- *
Screen-reader virtual nodes need a rectangle per slot, and the wheel draws its sectors on
- * a band, so the centre of the band at the sector's mid-angle is the one point every slot has in
- * common. Kept here with the rest of the geometry so the accessibility targets and the painted
- * sectors cannot disagree about where a slot is.
- *
- * @param index the slot index
- * @param count the number of slots
- * @param centerX the wheel centre x
- * @param centerY the wheel centre y
- * @param innerRadius the ring's inner radius
- * @param outerRadius the ring's outer radius
- * @return {x, y} of the slot centre
- */
- public static float[] slotCenter(int index, int count, float centerX, float centerY,
- float innerRadius, float outerRadius) {
- double deg = WheelLayout.sectorCenterDegrees(index, count);
- float radius = (innerRadius + outerRadius) / 2f;
- double a = Math.toRadians(deg);
- return new float[]{centerX + (float) (Math.cos(a) * radius),
- centerY + (float) (Math.sin(a) * radius)};
- }
-
- /** The quadrant sector for an angle in degrees (screen space, y down). */
- public static Sector quadrantFor(double deg) {
- if (deg >= -135.0 && deg < -45.0) return Sector.MODS;
- if (deg >= -45.0 && deg < 45.0) return Sector.CLOSET;
- if (deg >= 45.0 && deg < 135.0) return Sector.SETTINGS;
- return Sector.HUD;
- }
-
- /**
- * The sector under a pointer.
- *
- * @param dx pointer x minus the wheel centre x
- * @param dy pointer y minus the wheel centre y
- * @param innerRadius the inner edge of the sectors (the head hub radius); inside this is CENTER
- * @param outerRadius the outer edge of the sectors; outside this is NONE
- * @param gapDegrees half-width of the dead gap on each sector boundary
- */
- public static Sector sectorAt(float dx, float dy, float innerRadius, float outerRadius,
- float gapDegrees) {
- float distance = (float) Math.hypot(dx, dy);
- if (distance <= innerRadius) return Sector.CENTER;
- if (distance > outerRadius) return Sector.NONE;
-
- // Screen-space angle: atan2(dy, dx) with y down, in [-180, 180).
- double deg = Math.toDegrees(Math.atan2(dy, dx));
- if (deg < -180.0) deg += 360.0;
-
- // Distance to the nearest cardinal boundary (±45, ±135). A hit within the gap is dropped.
- double nearestBoundary = nearestBoundaryDistance(deg);
- if (nearestBoundary < gapDegrees) return Sector.NONE;
-
- return quadrantFor(deg);
- }
-
- /** The angular distance from {@code deg} to the nearest sector boundary (±45, ±135). */
- static double nearestBoundaryDistance(double deg) {
- double[] boundaries = {-135.0, -45.0, 45.0, 135.0};
- double best = Double.MAX_VALUE;
- for (double b : boundaries) {
- double d = Math.abs(wrap180(deg - b));
- best = Math.min(best, d);
- }
- return best;
- }
-
- // --- Enabled-module list (pure layout) ---------------------------------------------------
-
- /**
- * The compact row pitch of the enabled-module list, in dp.
- *
- *
The list lives on the right of the wheel and reads as one grouped stack, so the pitch is a
- * row height (a 22dp chip plus a 4dp gap), not the wide vertical spread the old three-cluster
- * layout used.
- */
- public static final float LIST_ROW_DP = 26f;
- /** The list's vertical band as fractions of the view height; a long column is clamped to it. */
- public static final float LIST_BAND_TOP_FY = 0.12f;
- public static final float LIST_BAND_BOTTOM_FY = 0.90f;
- /** The gap between the wheel's outer radius and the near edge of a module column, in dp. */
- public static final float LIST_RING_GAP_DP = 14f;
- /** The widest the list is allowed to be, as a fraction of the view width. */
- public static final float LIST_MAX_WIDTH_FRACTION = 0.30f;
-
- /**
- * The centre points of a module-list column.
- *
- *
The wheel has two compact columns of module rows: the left one is the quick options the
- * player can turn on, the right one is what is currently enabled. Both read as grouped stacks
- * (a single row pitch apart) rather than the old widely-spaced clusters, and a long column is
- * compressed so it still fits the safe band.
- *
- *
Each column is anchored to the wheel's outer radius plus a gap , not to the screen
- * edge (an edge-anchored column drifts far from the ring it belongs to on wide screens), and is
- * centred vertically on the hub. A very long list is clamped to the vertical safe band so it
- * never leaves the screen.
- *
- * @param w view width in px
- * @param h view height in px
- * @param count number of rows
- * @param density display density (dp -> px)
- * @param fromRight true for the right column, false for the left
- * @return an array of {@code {x, y}} pairs, one per row
- */
- public static float[][] moduleListCenters(int w, int h, int count, float density,
- boolean fromRight) {
- int safe = Math.max(0, count);
- float[][] out = new float[safe][2];
- if (safe == 0) return out;
- float outer = outerRadius(w, h);
- float cx = w / 2f;
- float cy = h / 2f;
- float gap = LIST_RING_GAP_DP * density;
- float x = fromRight ? cx + outer + gap : cx - outer - gap;
- float rowPitch = LIST_ROW_DP * density;
- float bandTop = h * LIST_BAND_TOP_FY;
- float bandBottom = h * LIST_BAND_BOTTOM_FY;
- float columnHeight = safe <= 1 ? 0f : (safe - 1) * rowPitch;
- float bandHeight = Math.max(rowPitch, bandBottom - bandTop);
- // Compress the pitch only when the list cannot fit at its natural spacing.
- if (columnHeight > bandHeight && safe > 1) {
- rowPitch = bandHeight / (safe - 1);
- columnHeight = bandHeight;
- }
- // Centre the column vertically on the hub, then clamp a long list into the safe band.
- float top = cy - columnHeight / 2f;
- if (top < bandTop) top = bandTop;
- for (int i = 0; i < safe; i++) {
- out[i][0] = x;
- out[i][1] = top + i * rowPitch;
- }
- return out;
- }
-
- /** The right-hand enabled-module column (right-anchored). */
- public static float[][] enabledListCenters(int w, int h, int count, float density) {
- return moduleListCenters(w, h, count, density, true);
- }
-
- private static double wrap180(double deg) {
- double d = deg % 360.0;
- if (d >= 180.0) d -= 360.0;
- if (d < -180.0) d += 360.0;
- return d;
- }
-
- /** The centre angle of a sector, in degrees, for laying out its icon and label. */
- public static double sectorCenterDegrees(Sector sector) {
- switch (sector) {
- case MODS: return -90.0;
- case CLOSET: return 0.0;
- case SETTINGS: return 90.0;
- case HUD: return 180.0;
- default: return 0.0;
- }
- }
-
- /** True when a sector is one of the four real destinations (not the hub or "none"). */
- public static boolean isDestination(Sector sector) {
- return sector == Sector.MODS || sector == Sector.CLOSET
- || sector == Sector.SETTINGS || sector == Sector.HUD;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelMaterial.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelMaterial.java
deleted file mode 100644
index 04b97501..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelMaterial.java
+++ /dev/null
@@ -1,407 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * The radial wheel's glass material, as pure arithmetic (R2, R8, R9, R10, R12).
- *
- *
The wheel floats over a live game, so its surface is glass in three named thicknesses rather
- * than one arbitrary translucency. Every input that changes what can actually be drawn — the API
- * level ({@code RenderEffect} is API 31+), whether a backdrop capture succeeded, the OS
- * "reduce transparency" and "high contrast" settings, the device's thermal state and the FPS
- * optimizer's blur budget — is resolved here into one {@link Resolved} value. The view then paints
- * that value and owns no rules of its own, which is what makes "the wheel went solid on my phone" a
- * testable decision instead of a mystery.
- *
- *
Android-free: the API level and the capability flags are passed in, so every branch is unit
- * tested without a device.
- */
-public final class RadialWheelMaterial {
-
- private RadialWheelMaterial() {
- }
-
- /** The three glass thicknesses (R2), plus the solid fallback when no blur is possible. */
- public enum Tier {
- /** Header bars: the lightest blur, the clearest fill. */
- ULTRA_THIN,
- /** The panel backdrop: the middle thickness. */
- REGULAR,
- /** A raised sheet over the panel: the heaviest blur. */
- THICK,
- /** No blur available: an opaque-ish fill with a hairline, still legible. */
- SOLID
- }
-
- /** The layer a surface is drawn as, which picks its default tier. */
- public enum Role {
- /** The whole panel backdrop behind the wheel. */
- PANEL,
- /** A header bar inside the panel. */
- HEADER,
- /** A sector of the ring. */
- SEGMENT
- }
-
- /** Blur radii in dp, one per tier (R2). */
- public static final float ULTRA_THIN_BLUR_DP = 24f;
- public static final float REGULAR_BLUR_DP = 32f;
- public static final float THICK_BLUR_DP = 40f;
- /** The solid tier's nominal radius; it is never blurred. */
- public static final float SOLID_BLUR_DP = 0f;
-
- /** White tint alpha, one per tier (R2). */
- public static final float ULTRA_THIN_TINT = 0.08f;
- public static final float REGULAR_TINT = 0.14f;
- public static final float THICK_TINT = 0.26f;
- /** The solid tier carries no white tint; its legibility comes from the fill. */
- public static final float SOLID_TINT = 0.0f;
-
- /**
- * The black scrim floor (R2): every tier sits on at least this much black, so a very bright
- * frame cannot wash the ring's labels out. Without a floor the thinnest tier disappears over a
- * snow biome.
- */
- public static final float SCRIM_FLOOR_ALPHA = 0.12f;
-
- /** The minimum contrast ratio a label needs against the worst-case frame (R9). */
- public static final float MIN_CONTRAST = 4.5f;
-
- /** {@code RenderEffect} and the blur-backed capture path arrive at API 31 (R12). */
- public static final int API_RENDER_EFFECT = 31;
-
- /** The solid fill alpha for a fallback surface: legible over any frame. */
- public static final int SOLID_FILL_ALPHA = 0xE6;
- /** The glass fill alpha at each tier, before the white tint is laid over it. */
- public static final int ULTRA_THIN_FILL_ALPHA = 0x66;
- public static final int REGULAR_FILL_ALPHA = 0x8C;
- public static final int THICK_FILL_ALPHA = 0xB3;
-
- /** How far the blur budget can be degraded under thermal pressure (R8). */
- public enum BlurBudget {
- /** Full blur, refreshed at the configured rate. */
- FULL,
- /** Cheaper: the heavier tiers collapse to the lightest blur. */
- REDUCED,
- /** No blur at all: every tier draws as SOLID. */
- NONE
- }
-
- /** The default blur refresh ceiling, in frames per second (R3). */
- public static final int BLUR_FPS_DEFAULT = 5;
- /** The highest refresh a user may pick; past this the capture costs more than it adds (R3). */
- public static final int BLUR_FPS_MAX = 10;
-
- /**
- * Everything that can change how the wheel is drawn.
- *
- *
An explicit holder rather than a long parameter list, so a new signal is one field and the
- * callers that do not care about it are unaffected.
- */
- public static final class Env {
- public int apiLevel = API_RENDER_EFFECT;
- /** Whether a {@code PixelCopy} of the window succeeded (R3/R12). */
- public boolean pixelCopyOk = true;
- /** The OS "reduce transparency" setting (R10). */
- public boolean reduceTransparency;
- /** The OS "high contrast" setting (R10). */
- public boolean highContrast;
- /** Platform thermal severity, 0 (cool) upward. */
- public int thermalSeverity;
- public boolean batterySaver;
- public boolean lowMemory;
- /** Whether a game session is running; the budget only matters then. */
- public boolean sessionActive;
- /** The app's own reduce-motion switch (R10). */
- public boolean reduceMotion;
- /** The configured blur refresh ceiling in fps (R3). */
- public int blurFps = BLUR_FPS_DEFAULT;
- /**
- * The blur budget decided by the launcher's FPS Optimization coordinator (R8).
- *
- *
When set, it wins over the local thermal reading: the coordinator is the single place
- * that decides how hard the launcher may work during a session, and the wheel must not
- * re-derive a different answer from the same sensors.
- */
- public BlurBudget budgetOverride;
-
- public Env apiLevel(int level) {
- this.apiLevel = level;
- return this;
- }
-
- public Env pixelCopyOk(boolean ok) {
- this.pixelCopyOk = ok;
- return this;
- }
-
- public Env reduceTransparency(boolean reduce) {
- this.reduceTransparency = reduce;
- return this;
- }
-
- public Env highContrast(boolean high) {
- this.highContrast = high;
- return this;
- }
-
- public Env thermal(int severity, boolean batterySaver, boolean lowMemory) {
- this.thermalSeverity = severity;
- this.batterySaver = batterySaver;
- this.lowMemory = lowMemory;
- return this;
- }
-
- public Env sessionActive(boolean active) {
- this.sessionActive = active;
- return this;
- }
-
- public Env reduceMotion(boolean reduce) {
- this.reduceMotion = reduce;
- return this;
- }
-
- public Env blurFps(int fps) {
- this.blurFps = fps;
- return this;
- }
-
- public Env budget(BlurBudget budget) {
- this.budgetOverride = budget;
- return this;
- }
- }
-
- /** The material the wheel should draw with, after every fallback has been applied. */
- public static final class Resolved {
- public final Tier tier;
- public final float blurDp;
- /** The white wash laid over the blurred backdrop. */
- public final float tint;
- /** The panel fill alpha (the dark base under the blur and the tint). */
- public final int fillAlpha;
- /** The black scrim floor drawn behind everything. */
- public final float scrimAlpha;
- public final int blurFps;
- public final boolean outlinedLabels;
- public final boolean reducedMotion;
-
- Resolved(Tier tier, float blurDp, float tint, int fillAlpha, float scrimAlpha, int blurFps,
- boolean outlinedLabels, boolean reducedMotion) {
- this.tier = tier;
- this.blurDp = blurDp;
- this.tint = tint;
- this.fillAlpha = fillAlpha;
- this.scrimAlpha = scrimAlpha;
- this.blurFps = blurFps;
- this.outlinedLabels = outlinedLabels;
- this.reducedMotion = reducedMotion;
- }
-
- /** True when a real blur is in play (the caller may skip the capture otherwise). */
- public boolean blurs() {
- return blurDp > 0f;
- }
- }
-
- /** The default tier for a layer role. */
- public static Tier tierFor(Role role) {
- switch (role) {
- case HEADER:
- return Tier.ULTRA_THIN;
- case SEGMENT:
- return Tier.THICK;
- case PANEL:
- default:
- return Tier.REGULAR;
- }
- }
-
- /** The blur radius for a tier, in dp. */
- public static float blurDp(Tier tier) {
- switch (tier) {
- case ULTRA_THIN: return ULTRA_THIN_BLUR_DP;
- case REGULAR: return REGULAR_BLUR_DP;
- case THICK: return THICK_BLUR_DP;
- default: return SOLID_BLUR_DP;
- }
- }
-
- /** The dark panel fill alpha for a tier. */
- public static int fillAlpha(Tier tier) {
- switch (tier) {
- case ULTRA_THIN: return ULTRA_THIN_FILL_ALPHA;
- case REGULAR: return REGULAR_FILL_ALPHA;
- case THICK: return THICK_FILL_ALPHA;
- default: return SOLID_FILL_ALPHA;
- }
- }
-
- /** The white tint alpha for a tier. */
- public static float tint(Tier tier) {
- switch (tier) {
- case ULTRA_THIN: return ULTRA_THIN_TINT;
- case REGULAR: return REGULAR_TINT;
- case THICK: return THICK_TINT;
- default: return SOLID_TINT;
- }
- }
-
- /**
- * The blur budget for the current device state (R8).
- *
- *
This is the wheel's half of the thermal ladder: it reads the same signals the FPS
- * optimizer coordinates, so the wheel cannot keep paying for a per-open blur capture while the
- * launcher has already decided the host is under pressure. The budget only bites during a
- * session — with no game running there is nothing to protect.
- */
- public static BlurBudget budgetFor(int thermalSeverity, boolean batterySaver, boolean lowMemory,
- boolean sessionActive) {
- if (!sessionActive) return BlurBudget.FULL;
- if (thermalSeverity >= 3 || batterySaver || lowMemory) return BlurBudget.NONE;
- if (thermalSeverity >= 1) return BlurBudget.REDUCED;
- return BlurBudget.FULL;
- }
-
- /** Degrades a tier to fit the budget. */
- public static Tier degrade(Tier tier, BlurBudget budget) {
- if (tier == null || tier == Tier.SOLID) return Tier.SOLID;
- switch (budget) {
- case NONE:
- return Tier.SOLID;
- case REDUCED:
- // The heavy tiers step down to the lightest blur; the lightest stays put.
- return tier == Tier.THICK ? Tier.ULTRA_THIN : tier;
- case FULL:
- default:
- return tier;
- }
- }
-
- /**
- * Resolves the material to draw with, applying the API-level, capability, accessibility and
- * thermal fallbacks in a fixed order (R8, R10, R12).
- *
- *
Order matters and is deliberate: the API/capability checks decide whether a blur can exist
- * at all, the accessibility settings then override the user's preference (an in-app choice must
- * never beat the OS), and the thermal ladder is applied last because it is a performance
- * concession, not a preference.
- */
- public static Resolved resolve(Tier requested, Env env) {
- Env e = env == null ? new Env() : env;
- Tier tier = requested == null ? Tier.REGULAR : requested;
-
- // R12: no blur without RenderEffect, and none without a successful capture.
- if (e.apiLevel < API_RENDER_EFFECT || !e.pixelCopyOk) {
- tier = Tier.SOLID;
- }
- // R10: the OS accessibility settings win over the in-app choice.
- if (e.reduceTransparency) {
- tier = Tier.SOLID;
- }
- // R8: the coordinator's budget when it supplied one, else the local pressure reading.
- BlurBudget budget = e.budgetOverride != null ? e.budgetOverride
- : budgetFor(e.thermalSeverity, e.batterySaver, e.lowMemory, e.sessionActive);
- tier = degrade(tier, budget);
-
- int fps = Math.max(0, Math.min(BLUR_FPS_MAX, e.blurFps));
- if (tier == Tier.SOLID) fps = 0;
- return new Resolved(tier, blurDp(tier), tint(tier), fillAlpha(tier), SCRIM_FLOOR_ALPHA,
- fps, e.highContrast, e.reduceMotion);
- }
-
- /**
- * Resolves the material for a known backdrop, raising the scrim to the contrast floor (R9).
- *
- *
Used when the wheel has a backdrop sample to judge: the label colour is the wheel's own
- * primary text, and the scrim only rises as far as that pair needs. With no sample the caller
- * uses {@link #resolve} and the fixed floor.
- */
- public static Resolved resolveForBackdrop(Tier requested, Env env, int textColor,
- int backdropColor) {
- Resolved base = resolve(requested, env);
- float escalated = escalatedScrim(textColor, backdropColor);
- if (escalated <= base.scrimAlpha) return base;
- return new Resolved(base.tier, base.blurDp, base.tint, base.fillAlpha, escalated,
- base.blurFps, base.outlinedLabels, base.reducedMotion);
- }
-
- /** The blur refresh interval in milliseconds for a rate, or {@code -1} when not blurred. */
- public static long blurIntervalMs(int fps) {
- if (fps <= 0) return -1L;
- return Math.max(1000L / BLUR_FPS_MAX, 1000L / fps);
- }
-
- /**
- * Which blur mechanism is available at an API level (R12).
- *
- *
This is the one place the two routes are chosen, so the renderer cannot decide one way for
- * the layer and another way for the canvas. {@code RenderEffect} arrives at API 31; below that
- * the only mechanism is the downscale/upscale stack blur.
- */
- public enum BlurRoute {
- /** Nothing to blur: a solid tier, or no capture. */
- NONE,
- /** API 28-30: the canvas stack blur. */
- STACK,
- /** API 31+: a {@code RenderEffect} on the backdrop layer. */
- RENDER_EFFECT
- }
-
- /** The blur route for an API level and a resolved material (R12). */
- public static BlurRoute blurRoute(int apiLevel, Resolved material) {
- if (material == null || !material.blurs()) return BlurRoute.NONE;
- return apiLevel >= API_RENDER_EFFECT ? BlurRoute.RENDER_EFFECT : BlurRoute.STACK;
- }
-
- /**
- * Whether a label at {@code textColor} is legible over {@code backdropColor} (R9).
- */
- public static boolean meetsContrast(int textColor, int backdropColor) {
- return contrastRatio(textColor, backdropColor) >= MIN_CONTRAST;
- }
-
- /**
- * The extra black scrim a label needs to reach the contrast floor over a frame, or 0 when the
- * frame is already dark enough (R9).
- *
- *
The escalation has to be black , not more white tint: the wheel's labels are light,
- * so over a bright frame the only direction that helps is darker. Raising the white tint over a
- * white frame changes nothing at all — which is exactly the defect this method exists to avoid.
- *
- *
Auto-escalation is the difference between "the labels are hard to read in the snow" and a
- * support ticket: the scrim rises only as far as it needs to, so the glass stays glass.
- */
- public static float escalatedScrim(int textColor, int backdropColor) {
- if (meetsContrast(textColor, backdropColor)) return 0f;
- // Walk the scrim up in steps until the floor is met; the loop is bounded so a pathological
- // pair cannot spin, and the final value is the last one tried.
- for (int i = 1; i <= 10; i++) {
- float t = i / 10f;
- int filled = composite(backdropColor, 0xFF000000, t);
- if (meetsContrast(textColor, filled)) return t;
- }
- return 1f;
- }
-
- /** Alpha-composites {@code over} at {@code amount} onto {@code base}. */
- public static int composite(int base, int over, float amount) {
- return org.chimeramc.client.ui.components.UiTokens.blend(base, over, amount);
- }
-
- /** The WCAG contrast ratio between two colours (delegates to the shared token maths). */
- public static double contrastRatio(int a, int b) {
- return org.chimeramc.client.ui.components.UiTokens.contrastRatio(a, b);
- }
-
- /**
- * The squircle corner's control-point ratio for a squareness in {@code 0..1} (R1/R13).
- *
- *
A circular fillet uses the classic 0.5523 ratio; a squircle pulls the control points out
- * toward the corner. The value is exposed because the path builder is not unit-testable and this
- * is the part of the shape that is.
- */
- public static float squircleControl(float squareness) {
- float s = Math.max(0f, Math.min(1f, squareness));
- return 0.5523f + s * (1f - 0.5523f);
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelMotion.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelMotion.java
deleted file mode 100644
index 45efc5e5..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelMotion.java
+++ /dev/null
@@ -1,233 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-/**
- * The radial wheel's motion, haptic, label and cancel rules, as pure values (R4-R7, R11).
- *
- *
A wheel is judged by how it feels, and "feel" is exactly the part that is normally buried in a
- * view's {@code ValueAnimator} calls where nothing can test it. The spring targets, the haptic
- * vocabulary, the label truncation and the cancel threshold are named here so the view only applies
- * them, and so a change to one of them cannot silently disagree with the rest (the open scale and
- * the selection scale must stay in a fixed relationship or the ring looks like it twitches).
- *
- *
Android-free: the haptic "what to play" decision is a plain enum, so the choice is unit tested
- * and the view maps it onto {@code HapticFeedbackConstants}.
- */
-public final class RadialWheelMotion {
-
- private RadialWheelMotion() {
- }
-
- // --- Spring targets (R4) ------------------------------------------------------------------
-
- /** The ring's scale at the start of the open animation. */
- public static final float OPEN_FROM_SCALE = 0.86f;
- /** The ring's resting scale once open. */
- public static final float OPEN_TO_SCALE = 1.0f;
- public static final float OPEN_DAMPING = 0.78f;
- public static final float OPEN_STIFFNESS = 380f;
-
- /** The scale a sector springs to when the pointer selects it. */
- public static final float SELECT_SCALE = 1.06f;
- public static final float SELECT_DAMPING = 0.7f;
- public static final float SELECT_STIFFNESS = 500f;
-
- /** The brief overshoot applied on commit, and how long it lasts. */
- public static final float SETTLE_SCALE = 1.14f;
- public static final long SETTLE_MS = 90L;
-
- /** The reduced-motion duration for every wheel transition (R10). */
- public static final long REDUCED_MS = 120L;
-
- /**
- * The scale the ring should be at during the open animation.
- *
- *
A pure lerp so the view's animator only feeds a fraction; the endpoints live here so the
- * open and the close cannot use different numbers.
- */
- public static float openScale(float fraction) {
- float f = Math.max(0f, Math.min(1f, fraction));
- return OPEN_FROM_SCALE + (OPEN_TO_SCALE - OPEN_FROM_SCALE) * f;
- }
-
- /** The selection scale for a sector: the resting scale unless it is selected. */
- public static float sectorScale(boolean selected) {
- return selected ? SELECT_SCALE : 1f;
- }
-
- // --- Haptics (R5) -------------------------------------------------------------------------
-
- /** The haptic vocabulary. The view maps these onto the platform constants. */
- public enum Haptic {
- /** Nothing: opening the wheel must not buzz. */
- NONE,
- /** A light tick when the pointer crosses into a new segment. */
- TICK,
- /** A medium pulse when a selection is committed. */
- COMMIT
- }
-
- /**
- * The centre dead zone bounds and default, in dp.
- *
- *
The dead zone is the radius around the hub in which stick movement is ignored, so a
- * resting-but-noisy stick cannot drift the cursor off the hub and highlight a slice the player
- * did not aim at. It is a user setting because pad wear varies widely: a worn stick needs a
- * larger dead zone than a new one, and a value that is right for one pad makes another feel
- * unresponsive. The bounds keep the wheel usable at both ends.
- */
- public static final float CENTER_DEAD_ZONE_MIN_DP = 0.05f;
- public static final float CENTER_DEAD_ZONE_MAX_DP = 0.45f;
- public static final float CENTER_DEAD_ZONE_DEFAULT_DP = 0.15f;
-
- /**
- * Clamps a requested centre dead zone into the usable range.
- *
- *
A non-finite value reads as the default rather than propagating NaN into the shape maths,
- * where it would silence the cursor entirely with no diagnosable cause.
- */
- public static float clampCenterDeadZone(float requested) {
- if (Float.isNaN(requested) || Float.isInfinite(requested)) {
- return CENTER_DEAD_ZONE_DEFAULT_DP;
- }
- return Math.max(CENTER_DEAD_ZONE_MIN_DP, Math.min(CENTER_DEAD_ZONE_MAX_DP, requested));
- }
-
- /**
- * The haptic for a wheel event.
- *
- * @param opened the wheel just opened
- * @param crossedSegment the pointer moved into a different segment
- * @param committed a selection was confirmed
- */
- public static Haptic hapticFor(boolean opened, boolean crossedSegment, boolean committed) {
- // Commit outranks everything: it is the only signal that must never be missed.
- if (committed) return Haptic.COMMIT;
- if (crossedSegment) return Haptic.TICK;
- return Haptic.NONE;
- }
-
- // --- Labels and hub (R6, R7) ---------------------------------------------------------------
-
- /** The segment icon size in dp (R6). */
- public static final float ICON_DP = 24f;
- /** The segment label size in sp (R6). */
- public static final float LABEL_SP = 11f;
- /** The label weight axis value (R6). */
- public static final int LABEL_WEIGHT = 500;
- /** A label longer than this is ellipsised, because the segment cannot grow to fit it (R6). */
- public static final int LABEL_MAX_CHARS = 9;
-
- /** The hub's squircle head size in dp (R7). */
- public static final float HUB_HEAD_DP = 40f;
- /** The hub's active-label size in sp (R7). */
- public static final float HUB_LABEL_SP = 10f;
- /** The hub label's white alpha (R7). */
- public static final float HUB_LABEL_ALPHA = 0.60f;
-
- /**
- * Truncates a label to the segment's budget, appending an ellipsis (R6).
- *
- *
Deliberately a character budget rather than a pixel measurement: the label is drawn
- * centred in a wedge, and a measured ellipsis would make two sectors with the same word
- * truncate at different points depending on their screen angle. A null label reads as empty.
- */
- public static String ellipsise(String label) {
- if (label == null) return "";
- String trimmed = label.trim();
- if (trimmed.length() <= LABEL_MAX_CHARS) return trimmed;
- return trimmed.substring(0, LABEL_MAX_CHARS - 1) + "\u2026";
- }
-
- // --- Cancel gesture (R11) -----------------------------------------------------------------
-
- /** How far past the outer radius a drag must go before it reads as a cancel. */
- public static final float CANCEL_MARGIN_DP = 24f;
- /** The ring's opacity while the cancel gesture is armed. */
- public static final float CANCEL_OPACITY = 0.4f;
- /** The ring's scale while the cancel gesture is armed. */
- public static final float CANCEL_SCALE = 0.94f;
-
- /**
- * Whether a drag distance from the wheel centre has armed the cancel gesture (R11).
- *
- *
The margin is a constant linear distance, not a fraction of the radius: a fraction would
- * make the gesture harder on a small screen exactly where it is most needed.
- */
- public static boolean isCancelArmed(float distancePx, float outerRadiusPx, float density) {
- float margin = CANCEL_MARGIN_DP * Math.max(0.5f, density);
- return distancePx > outerRadiusPx + margin;
- }
-
- /** The ring's opacity for a cancel-armed state. */
- public static float cancelOpacity(boolean armed) {
- return armed ? CANCEL_OPACITY : 1f;
- }
-
- /** The ring's scale for a cancel-armed state. */
- public static float cancelScale(boolean armed) {
- return armed ? CANCEL_SCALE : 1f;
- }
-
- // --- Segment geometry (R1) -----------------------------------------------------------------
-
- /**
- * The most segments the wheel will draw; past this the wedges are too thin to read (R1 caps the
- * wheel at 6-8). Shared with the profile wheel's own cap so the two cannot drift.
- */
- public static final int MAX_SEGMENTS = WheelLayout.MAX_SECTORS;
- /** The fewest a wheel may be built with; below this it is a pair of buttons. */
- public static final int MIN_SEGMENTS = 2;
- /** The minimum arc length of a segment's outer edge, in dp (R1's 48dp target). */
- public static final float MIN_SEGMENT_ARC_DP = 48f;
- /** The annulus' inner edge (R1), re-exported from the layout so there is one definition. */
- public static final float ANNULUS_INNER_FRACTION = RadialWheelLayout.ANNULUS_INNER_FRACTION;
- /** The annulus' outer edge (R1), re-exported from the layout so there is one definition. */
- public static final float ANNULUS_OUTER_FRACTION = RadialWheelLayout.ANNULUS_OUTER_FRACTION;
-
- /** Clamps a requested segment count into the drawable range. */
- public static int clampSegments(int requested) {
- return Math.max(MIN_SEGMENTS, Math.min(MAX_SEGMENTS, requested));
- }
-
- /** The arc length of one segment's outer edge, in px. */
- public static float segmentArcPx(float outerRadiusPx, int segments) {
- int count = clampSegments(segments);
- return (float) (2.0 * Math.PI * outerRadiusPx / count);
- }
-
- /**
- * Whether a wheel of this many segments has 48dp targets at this radius (R1).
- *
- *
If it does not, the caller must reduce the count rather than draw targets the player
- * cannot hit; that is why this is a query and not an assertion.
- */
- public static boolean hasTouchTargets(float outerRadiusPx, int segments, float density) {
- return segmentArcPx(outerRadiusPx, segments) >= MIN_SEGMENT_ARC_DP * Math.max(0.5f, density);
- }
-
- /** The largest segment count that still gives every segment a 48dp target at this radius. */
- public static int maxSegmentsForRadius(float outerRadiusPx, float density) {
- for (int n = MAX_SEGMENTS; n > MIN_SEGMENTS; n--) {
- if (hasTouchTargets(outerRadiusPx, n, density)) return n;
- }
- return MIN_SEGMENTS;
- }
-
- /**
- * The segment count a wheel actually draws and hit-tests for a given entry count (R1).
- *
- *
The entry count is capped at {@link #MAX_SEGMENTS}, then reduced further if the screen is
- * too small to give every segment a 48dp target — R1 requires the wheel to drop segments rather
- * than draw targets the player cannot hit. A one-entry wheel is a ring and is never reduced.
- *
- *
This is the single rule the draw path and the hit test both read. When they compute the
- * count separately, a tap lands on the wrong sector, and that is a defect a screenshot cannot
- * catch.
- */
- public static int segmentsFor(int entryCount, float outerRadiusPx, float density) {
- if (entryCount <= 1) return Math.max(0, entryCount);
- int requested = clampSegments(entryCount);
- int fits = maxSegmentsForRadius(outerRadiusPx, density);
- return Math.max(MIN_SEGMENTS, Math.min(requested, fits));
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RenderDebugOverlay.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RenderDebugOverlay.java
deleted file mode 100644
index 19b39520..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RenderDebugOverlay.java
+++ /dev/null
@@ -1,139 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.content.Context;
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.os.Handler;
-import android.os.SystemClock;
-import android.os.Looper;
-import android.view.View;
-import android.widget.FrameLayout;
-import android.widget.LinearLayout;
-import android.widget.TextView;
-
-import org.chimeramc.client.core.cosmetics.CosmeticsDiagnosticsReport;
-import org.chimeramc.client.core.cosmetics.RenderDebugLines;
-import org.chimeramc.client.ui.components.UiTokens;
-import org.chimeramc.client.util.MainThread;
-import org.chimeramc.client.util.OverlayRefreshGate;
-
-import java.util.List;
-
-/**
- * The live cosmetics-render debug overlay: a small translucent panel that prints the native render
- * values and refreshes once a second, so a device can watch the draw path in third person without
- * leaving the game.
- *
- *
It is the on-device instrument for "cosmetics still not showing". It reads the same values the
- * diagnostics screen does (via {@link CosmeticsDiagnosticsReport}) and formats them through the pure
- * {@link RenderDebugLines}, so the wording is unit-tested. It draws nothing when the toggle is off.
- */
-public final class RenderDebugOverlay {
-
- private final Context context;
- private final TextView text;
- private final LinearLayout root;
- private final Handler handler = MainThread.handler();
- private boolean running;
- private final OverlayRefreshGate refreshGate = new OverlayRefreshGate(1000L);
-
- private final Runnable tick = new Runnable() {
- @Override
- public void run() {
- if (!running) return;
- refresh();
- handler.postDelayed(this, 1000L);
- }
- };
-
- private RenderDebugOverlay(Context context) {
- this.context = context;
- root = new LinearLayout(context);
- root.setOrientation(LinearLayout.VERTICAL);
- root.setPadding(dp(10), dp(8), dp(10), dp(8));
- android.graphics.drawable.GradientDrawable bg =
- new android.graphics.drawable.GradientDrawable();
- bg.setColor(UiTokens.withAlpha(0xFF000000, 0xC0));
- bg.setCornerRadius(dp(8));
- bg.setStroke(Math.max(1, dp(1)), UiTokens.withAlpha(0xFFFFFFFF, 0x33));
- root.setBackground(bg);
-
- text = new TextView(context);
- text.setTextColor(UiTokens.TEXT_PRIMARY);
- text.setTextSize(11f);
- text.setTypeface(android.graphics.Typeface.MONOSPACE);
- root.addView(text);
- }
-
- /** A view to add to a window, sized to its content. */
- public View getView() {
- return root;
- }
-
- /** Starts refreshing (once a second) while the overlay is shown. */
- public void start() {
- running = true;
- refreshGate.reset();
- refresh();
- handler.removeCallbacks(tick);
- handler.postDelayed(tick, 1000L);
- }
-
- /** Stops refreshing. */
- public void stop() {
- running = false;
- handler.removeCallbacks(tick);
- }
-
- private void refresh() {
- try {
- CosmeticsDiagnosticsReport.Inputs in =
- CosmeticsDiagnosticsReport.readInputs(context);
- RenderDebugLines.Values v = new RenderDebugLines.Values();
- v.nativeLoaded = in.nativeLoaded;
- v.renderSlot = in.renderSlot;
- v.hookInstalled = in.hookInstalled;
- v.framesSeen = in.framesSeen;
- v.capeEngineRouteLive = in.capeEngineRouteLive;
- v.glBackend = in.glBackend;
- v.eglContextCurrent = in.eglContextCurrent;
- v.geometryBytes = in.geometryBytes;
- v.pieceAtlasMask = in.pieceAtlasMask;
- List lines = RenderDebugLines.lines(v);
- StringBuilder b = new StringBuilder();
- for (String line : lines) b.append(line).append('\n');
- String rendered = b.toString().trim();
- // The readout is a diagnostics dump, so a SetText on an unchanged dump is pure cost.
- // The gate rebuilds it on change and repaints at most once per interval otherwise
- //.
- if (refreshGate.shouldRefresh(rendered, SystemClock.uptimeMillis())) {
- text.setText(rendered);
- }
- } catch (Throwable t) {
- if (refreshGate.shouldRefresh("render debug unavailable", SystemClock.uptimeMillis())) {
- text.setText("render debug unavailable");
- }
- }
- }
-
- private int dp(float v) {
- return Math.round(v * context.getResources().getDisplayMetrics().density);
- }
-
- /**
- * Creates the overlay and adds it to a window, positioned top-start. The caller owns its
- * lifetime (add once, remove on hide) and calls {@link #start()}/{@link #stop()}.
- */
- public static RenderDebugOverlay attach(Context context, FrameLayout host) {
- if (context == null || host == null) return null;
- RenderDebugOverlay overlay = new RenderDebugOverlay(context);
- FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
- FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT);
- lp.gravity = android.view.Gravity.TOP | android.view.Gravity.START;
- lp.topMargin = (int) (72 * context.getResources().getDisplayMetrics().density);
- lp.leftMargin = (int) (12 * context.getResources().getDisplayMetrics().density);
- host.addView(overlay.getView(), lp);
- return overlay;
- }
-}
diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RuntimeConfigView.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RuntimeConfigView.java
deleted file mode 100644
index 203f564f..00000000
--- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RuntimeConfigView.java
+++ /dev/null
@@ -1,1168 +0,0 @@
-package org.chimeramc.client.core.mods.inbuilt.overlay;
-
-import android.content.Context;
-import android.content.res.ColorStateList;
-import android.graphics.Color;
-import android.graphics.drawable.GradientDrawable;
-import android.os.Handler;
-import android.os.Looper;
-import android.text.Editable;
-import android.text.TextWatcher;
-import android.view.Gravity;
-import android.view.KeyEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.Button;
-import android.widget.CheckBox;
-import android.widget.EditText;
-import android.widget.FrameLayout;
-import android.widget.LinearLayout;
-import android.widget.RadioButton;
-import android.widget.RadioGroup;
-import android.widget.SeekBar;
-import android.widget.Spinner;
-import android.widget.ArrayAdapter;
-import android.widget.Switch;
-import android.widget.TextView;
-
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.view.ContextThemeWrapper;
-import androidx.recyclerview.widget.DiffUtil;
-import androidx.recyclerview.widget.ItemTouchHelper;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-
-import com.google.android.material.chip.Chip;
-import com.google.android.material.chip.ChipGroup;
-import com.google.android.material.slider.RangeSlider;
-
-import org.json.JSONArray;
-import org.chimeramc.client.core.mods.inbuilt.ExternalModBridge;
-import org.chimeramc.client.core.mods.inbuilt.RuntimeConfigControl;
-import org.chimeramc.client.core.mods.inbuilt.RuntimeConfigSchema;
-import org.chimeramc.client.core.mods.inbuilt.UnifiedMod;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Set;
-import java.util.WeakHashMap;
-import org.chimeramc.client.R;
-import org.chimeramc.client.util.HexColor;
-import org.chimeramc.client.util.MainThread;
-
-final class RuntimeConfigView {
- private static final WeakHashMap ACTIVE = new WeakHashMap<>();
- private static final int ACCENT = 0xFF0F5BE4;
- private static final int TEXT_PRIMARY = 0xFFF1F4F6;
- private static final int TEXT_SECONDARY = 0xFFA8B0B8;
- private static final int CARD = 0xFF202428;
- private static final int CARD_SELECTED = 0xFF27312E;
-
- private final Context context;
- private final ViewGroup host;
- private final UnifiedMod mod;
- private final boolean compact;
- private final Runnable onConfigChanged;
- private final Handler handler = MainThread.handler();
- private final Set collapsedSections = new HashSet<>();
- private RuntimeConfigSchema schema;
- private String selectedCategory = "";
- private long revision;
- private RecyclerView categoryRecycler;
- private RecyclerView contentRecycler;
- private CategoryAdapter categoryAdapter;
- private NodeAdapter nodeAdapter;
- private boolean stopped;
-
- private final Runnable pollRunnable = new Runnable() {
- @Override
- public void run() {
- if (stopped) return;
- if (host.getWindowToken() == null) {
- handler.postDelayed(this, 900L);
- return;
- }
- if (mod.getSource() == UnifiedMod.Source.EXTERNAL) {
- long current = ExternalModBridge.getExternalModConfigSchemaRevision(mod.getId());
- if (current != revision) {
- if (current <= 0) {
- stopped = true;
- handler.removeCallbacks(this);
- mod.setRuntimeConfigSchemaRevision(0L);
- ModConfigView.render(context, host, mod, compact, onConfigChanged);
- return;
- }
- String json = ExternalModBridge.getExternalModConfigSchema(mod.getId());
- RuntimeConfigSchema updated = RuntimeConfigSchema.parse(json);
- if (updated != null) {
- revision = current;
- mod.setRuntimeConfigSchemaRevision(current);
- applySchema(updated);
- }
- }
- }
- handler.postDelayed(this, 900L);
- }
- };
-
- private RuntimeConfigView(Context context, ViewGroup host, UnifiedMod mod,
- boolean compact, Runnable onConfigChanged) {
- // Minecraft's activity uses AppCompat; Chips and RangeSlider require Material.
- // Theme only this config view so the game activity and legacy menu keep their themes.
- this.context = new ContextThemeWrapper(context,
- com.google.android.material.R.style.Theme_MaterialComponents_NoActionBar);
- this.host = host;
- this.mod = mod;
- this.compact = compact;
- this.onConfigChanged = onConfigChanged != null ? onConfigChanged : () -> {};
- }
-
- static void stop(ViewGroup host) {
- if (host == null) return;
- RuntimeConfigView current = ACTIVE.remove(host);
- if (current != null) current.stop();
- }
-
- private void stop() {
- if (stopped) return;
- stopped = true;
- handler.removeCallbacks(pollRunnable);
- }
-
- static boolean render(Context context, ViewGroup host, UnifiedMod mod,
- boolean compact, Runnable onConfigChanged) {
- RuntimeConfigSchema schema = mod.getLocalConfigSchema();
- if (schema == null) {
- if (mod.getSource() != UnifiedMod.Source.EXTERNAL || mod.getRuntimeConfigSchemaRevision() <= 0) {
- return false;
- }
- schema = RuntimeConfigSchema.parse(ExternalModBridge.getExternalModConfigSchema(mod.getId()));
- }
- if (schema == null) return false;
- RuntimeConfigView view = new RuntimeConfigView(context, host, mod, compact, onConfigChanged);
- view.revision = mod.getRuntimeConfigSchemaRevision();
- view.build(schema);
- ACTIVE.put(host, view);
- return true;
- }
-
- private void build(RuntimeConfigSchema initial) {
- host.removeAllViews();
- LinearLayout root = new LinearLayout(context);
- root.setOrientation(compact ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL);
- root.setPadding(dp(compact ? 8 : 12), dp(8), dp(compact ? 8 : 12), dp(10));
- host.addView(root, new ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
-
- categoryRecycler = new RecyclerView(context);
- categoryRecycler.setOverScrollMode(View.OVER_SCROLL_NEVER);
- categoryRecycler.setLayoutManager(new LinearLayoutManager(context,
- compact ? RecyclerView.HORIZONTAL : RecyclerView.VERTICAL, false));
- categoryAdapter = new CategoryAdapter();
- categoryRecycler.setAdapter(categoryAdapter);
- LinearLayout.LayoutParams categoryParams = compact
- ? new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(48))
- : new LinearLayout.LayoutParams(dp(156), ViewGroup.LayoutParams.MATCH_PARENT);
- if (!compact) categoryParams.setMarginEnd(dp(12));
- else categoryParams.setMargins(0, 0, 0, dp(8));
- root.addView(categoryRecycler, categoryParams);
-
- contentRecycler = new RecyclerView(context);
- contentRecycler.setClipToPadding(false);
- contentRecycler.setPadding(0, 0, 0, dp(16));
- contentRecycler.setLayoutManager(new LinearLayoutManager(context));
- nodeAdapter = new NodeAdapter();
- contentRecycler.setAdapter(nodeAdapter);
- LinearLayout.LayoutParams contentParams = compact
- ? new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f)
- : new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
- root.addView(contentRecycler, contentParams);
-
- host.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
- @Override public void onViewAttachedToWindow(View v) {}
- @Override public void onViewDetachedFromWindow(View v) {
- stop();
- if (ACTIVE.get(host) == RuntimeConfigView.this) ACTIVE.remove(host);
- host.removeOnAttachStateChangeListener(this);
- }
- });
- applySchema(initial);
- if (mod.getSource() == UnifiedMod.Source.EXTERNAL) {
- handler.postDelayed(pollRunnable, 900L);
- }
- }
-
- private void applySchema(RuntimeConfigSchema updated) {
- schema = updated;
- for (RuntimeConfigSchema.Node node : updated.nodes) {
- if (!node.key.isEmpty() && node.hasCurrentValue) mod.setRuntimeConfigValue(node.key, node.currentValue);
- for (RuntimeConfigSchema.Option option : node.options) {
- if (option.hasCurrentValue) {
- String optionKey = option.key.isEmpty() ? option.value : option.key;
- if (!optionKey.isEmpty()) mod.setRuntimeConfigValue(optionKey, option.currentValue);
- }
- }
- }
- if (!hasCategory(selectedCategory)) {
- selectedCategory = hasCategory(updated.defaultCategory)
- ? updated.defaultCategory
- : (updated.categories.isEmpty() ? "" : updated.categories.get(0).id);
- }
- categoryAdapter.submit(updated.categories);
- refreshNodes();
- }
-
- private boolean hasCategory(String id) {
- if (schema == null || id == null || id.isEmpty()) return false;
- for (RuntimeConfigSchema.Category category : schema.categories) {
- if (id.equals(category.id)) return true;
- }
- return false;
- }
-
- private void refreshNodes() {
- if (schema == null || nodeAdapter == null) return;
- List result = new ArrayList<>();
- for (RuntimeConfigSchema.Node node : schema.nodes) {
- if (!selectedCategory.equals(node.category)) continue;
- if (!matches(node.visibleWhen)) continue;
- if (!"section".equals(node.type) && !node.section.isEmpty() && collapsedSections.contains(node.section)) {
- continue;
- }
- result.add(node);
- }
- nodeAdapter.submit(result);
- // Selecting a category repaints every tile (the selected one loses its highlight and the
- // new one gains it), but the tiles are the same rows, so a range change keeps the strip's
- // scroll position.
- if (categoryAdapter.getItemCount() > 0) {
- categoryAdapter.notifyItemRangeChanged(0, categoryAdapter.getItemCount());
- }
- }
-
- private boolean matches(List conditions) {
- for (RuntimeConfigSchema.Condition condition : conditions) {
- String actual = mod.getConfigValue(condition.key, "");
- switch (condition.op) {
- case "truthy":
- if (!truthy(actual)) return false;
- break;
- case "falsy":
- if (truthy(actual)) return false;
- break;
- case "not_equals":
- if (actual.equals(condition.value)) return false;
- break;
- case "contains":
- if (!containsValue(actual, condition.value)) return false;
- break;
- case "equals":
- default:
- if (!actual.equals(condition.value)) return false;
- break;
- }
- }
- return true;
- }
-
- private void setValue(String key, String value) {
- if (key == null || key.isEmpty()) return;
- mod.updateConfig(key, value);
- onConfigChanged.run();
- // Native config callbacks can replace dependent options synchronously.
- // Apply that schema on the next UI turn, outside RecyclerView/Spinner binding.
- handler.post(() -> {
- if (stopped) return;
- long current = mod.getSource() == UnifiedMod.Source.EXTERNAL
- ? ExternalModBridge.getExternalModConfigSchemaRevision(mod.getId()) : 0L;
- if (current > 0 && current != revision) {
- RuntimeConfigSchema updated = RuntimeConfigSchema.parse(
- ExternalModBridge.getExternalModConfigSchema(mod.getId()));
- if (updated != null) {
- revision = current;
- mod.setRuntimeConfigSchemaRevision(current);
- applySchema(updated);
- return;
- }
- }
- refreshNodes();
- });
- }
-
- private final class CategoryAdapter extends RecyclerView.Adapter {
- private List items = Collections.emptyList();
-
- CategoryAdapter() { setHasStableIds(true); }
-
- void submit(List next) {
- List old = items;
- List copy = new ArrayList<>(next);
- DiffUtil.DiffResult diff = DiffUtil.calculateDiff(new DiffUtil.Callback() {
- @Override public int getOldListSize() { return old.size(); }
- @Override public int getNewListSize() { return copy.size(); }
- @Override public boolean areItemsTheSame(int oldPos, int newPos) {
- return old.get(oldPos).id.equals(copy.get(newPos).id);
- }
- @Override public boolean areContentsTheSame(int oldPos, int newPos) {
- RuntimeConfigSchema.Category a = old.get(oldPos);
- RuntimeConfigSchema.Category b = copy.get(newPos);
- return a.title.equals(b.title) && a.description.equals(b.description);
- }
- });
- items = copy;
- diff.dispatchUpdatesTo(this);
- }
-
- @Override public long getItemId(int position) { return items.get(position).id.hashCode(); }
- @Override public CategoryHolder onCreateViewHolder(ViewGroup parent, int viewType) {
- TextView view = new TextView(context);
- view.setGravity(compact ? Gravity.CENTER : Gravity.CENTER_VERTICAL);
- view.setTextSize(13);
- view.setPadding(dp(12), 0, dp(12), 0);
- RecyclerView.LayoutParams params = compact
- ? new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(40))
- : new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(46));
- params.setMargins(compact ? dp(3) : 0, dp(2), compact ? dp(3) : 0, dp(2));
- view.setLayoutParams(params);
- return new CategoryHolder(view);
- }
- @Override public void onBindViewHolder(CategoryHolder holder, int position) {
- RuntimeConfigSchema.Category category = items.get(position);
- boolean selected = category.id.equals(selectedCategory);
- holder.label.setText(category.title);
- holder.label.setTextColor(selected ? ACCENT : TEXT_SECONDARY);
- holder.label.setTypeface(null, selected ? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL);
- holder.label.setBackground(roundRect(selected ? CARD_SELECTED : Color.TRANSPARENT, 10));
- holder.label.setOnClickListener(v -> {
- if (category.id.equals(selectedCategory)) return;
- selectedCategory = category.id;
- if (getItemCount() > 0) {
- notifyItemRangeChanged(0, getItemCount());
- }
- refreshNodes();
- contentRecycler.scrollToPosition(0);
- });
- }
- @Override public int getItemCount() { return items.size(); }
- }
-
- private static final class CategoryHolder extends RecyclerView.ViewHolder {
- final TextView label;
- CategoryHolder(TextView itemView) { super(itemView); label = itemView; }
- }
-
- private final class NodeAdapter extends RecyclerView.Adapter {
- private List items = Collections.emptyList();
-
- NodeAdapter() { setHasStableIds(true); }
-
- void submit(List next) {
- List old = items;
- List copy = new ArrayList<>(next);
- DiffUtil.DiffResult diff = DiffUtil.calculateDiff(new DiffUtil.Callback() {
- @Override public int getOldListSize() { return old.size(); }
- @Override public int getNewListSize() { return copy.size(); }
- @Override public boolean areItemsTheSame(int oldPos, int newPos) {
- return old.get(oldPos).id.equals(copy.get(newPos).id);
- }
- @Override public boolean areContentsTheSame(int oldPos, int newPos) {
- RuntimeConfigSchema.Node a = old.get(oldPos);
- RuntimeConfigSchema.Node b = copy.get(newPos);
- return a.signature.equals(b.signature);
- }
- });
- items = copy;
- diff.dispatchUpdatesTo(this);
- }
-
- @Override public long getItemId(int position) { return items.get(position).id.hashCode(); }
- @Override public NodeHolder onCreateViewHolder(ViewGroup parent, int viewType) {
- FrameLayout frame = new FrameLayout(context);
- RecyclerView.LayoutParams params = new RecyclerView.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.setMargins(0, dp(4), 0, dp(6));
- frame.setLayoutParams(params);
- return new NodeHolder(frame);
- }
- @Override public void onBindViewHolder(NodeHolder holder, int position) {
- RuntimeConfigSchema.Node node = items.get(position);
- holder.frame.removeAllViews();
- holder.frame.addView(buildNode(node));
- }
- @Override public int getItemCount() { return items.size(); }
- }
-
- private static final class NodeHolder extends RecyclerView.ViewHolder {
- final FrameLayout frame;
- NodeHolder(FrameLayout itemView) { super(itemView); frame = itemView; }
- }
-
- private View buildNode(RuntimeConfigSchema.Node node) {
- if ("section".equals(node.type)) return buildSection(node);
- LinearLayout card = new LinearLayout(context);
- card.setOrientation(LinearLayout.VERTICAL);
- card.setPadding(dp(12), dp(10), dp(12), dp(10));
- card.setBackground(roundRect(CARD, 12));
- boolean enabled = !node.disabled && matches(node.enabledWhen);
- addTitle(card, node.title, node.description);
-
- switch (node.type) {
- case "toggle": buildToggle(card, node); break;
- case "slider_int": buildIntSlider(card, node); break;
- case "slider_float": buildFloatSlider(card, node); break;
- case "range_slider": buildRangeSlider(card, node); break;
- case "choice": buildChoice(card, node); break;
- case "multi_choice": buildMultiChoice(card, node); break;
- case "toggle_group": buildToggleGroup(card, node); break;
- case "ordered_list": buildOrderedList(card, node); break;
- case "color": buildColor(card, node); break;
- case "keybind": buildKeybind(card, node); break;
- // Both text kinds share one builder; the multi-line rule lives in the pure control
- // rules so the type check is not repeated here.
- case "text":
- case "multiline_text": buildText(card, node, RuntimeConfigControl.multiline(node)); break;
- case "button": buildButton(card, node); break;
- case "info": break;
- default: break;
- }
- setEnabledRecursive(card, enabled);
- card.setAlpha(enabled ? 1f : 0.5f);
- return card;
- }
-
- private View buildSection(RuntimeConfigSchema.Node node) {
- LinearLayout row = new LinearLayout(context);
- row.setOrientation(LinearLayout.VERTICAL);
- row.setPadding(dp(4), dp(10), dp(4), dp(5));
- TextView title = new TextView(context);
- // A vector chevron rather than a triangle-glyph text marker: the spec bans emoji/symbol glyphs, and a
- // compound drawable keeps the indicator with the title at any font scale.
- title.setText(node.title);
- if (node.collapsible) {
- boolean collapsed = collapsedSections.contains(node.id);
- title.setCompoundDrawablesWithIntrinsicBounds(0, 0,
- collapsed ? R.drawable.ic_chevron_right : R.drawable.ic_arrow_down, 0);
- title.setCompoundDrawablePadding(dp(6));
- title.setCompoundDrawableTintList(ColorStateList.valueOf(TEXT_PRIMARY));
- }
- title.setTextColor(TEXT_PRIMARY);
- title.setTextSize(15);
- title.setTypeface(null, android.graphics.Typeface.BOLD);
- row.addView(title);
- if (!node.description.isEmpty()) {
- TextView desc = new TextView(context);
- desc.setText(node.description);
- desc.setTextColor(TEXT_SECONDARY);
- desc.setTextSize(11);
- row.addView(desc);
- }
- if (node.collapsible) {
- row.setOnClickListener(v -> {
- if (!collapsedSections.add(node.id)) collapsedSections.remove(node.id);
- refreshNodes();
- });
- }
- return row;
- }
-
- private void addTitle(LinearLayout parent, String titleText, String description) {
- TextView title = new TextView(context);
- title.setText(titleText);
- title.setTextColor(TEXT_PRIMARY);
- title.setTextSize(14);
- title.setTypeface(null, android.graphics.Typeface.BOLD);
- parent.addView(title);
- if (description != null && !description.isEmpty()) {
- TextView desc = new TextView(context);
- desc.setText(description);
- desc.setTextColor(TEXT_SECONDARY);
- desc.setTextSize(11);
- LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
- params.setMargins(0, dp(2), 0, dp(7));
- parent.addView(desc, params);
- } else {
- title.setPadding(0, 0, 0, dp(6));
- }
- }
-
- private void buildToggle(LinearLayout parent, RuntimeConfigSchema.Node node) {
- Switch control = new Switch(context);
- control.setChecked(truthy(mod.getConfigValue(node.key, node.defaultValue)));
- tintSwitch(control);
- control.setOnCheckedChangeListener((button, checked) -> setValue(node.key, checked ? "true" : "false"));
- parent.addView(control, alignEnd());
- }
-
- private void buildIntSlider(LinearLayout parent, RuntimeConfigSchema.Node node) {
- // The bounds/step/value rules live in RuntimeConfigControl so they are unit-tested (item
- // 203); the view only places the widget.
- RuntimeConfigControl.IntRange range = RuntimeConfigControl.intRange(
- node, mod.getConfigValue(node.key, node.defaultValue));
- TextView value = valueText(formatNumber(range.value, node.unit));
- parent.addView(value, alignEnd());
- SeekBar seek = new SeekBar(context);
- seek.setMin(0);
- seek.setMax(range.steps());
- seek.setProgress(range.positionOf(range.value));
- tintSeek(seek);
- seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- if (!fromUser) return;
- int next = range.valueAt(progress);
- value.setText(formatNumber(next, node.unit));
- setValue(node.key, String.valueOf(next));
- }
- @Override public void onStartTrackingTouch(SeekBar seekBar) {}
- @Override public void onStopTrackingTouch(SeekBar seekBar) {}
- });
- parent.addView(seek, matchWidth());
- }
-
- private void buildFloatSlider(LinearLayout parent, RuntimeConfigSchema.Node node) {
- RuntimeConfigControl.FloatRange bounds = RuntimeConfigControl.floatRange(
- node, mod.getConfigValue(node.key, node.defaultValue));
- float min = bounds.min;
- float max = bounds.max;
- float step = parseFloat(node.step, 0f);
- if (step <= 0f) step = Math.max((max - min) / 100f, 0.001f);
- float current = bounds.value;
- int count = Math.min(1000, Math.max(1, Math.round((max - min) / step)));
- final float actualStep = (max - min) / count;
- TextView value = valueText(formatFloat(current, node.unit));
- parent.addView(value, alignEnd());
- SeekBar seek = new SeekBar(context);
- seek.setMin(0);
- seek.setMax(count);
- seek.setProgress(Math.round((current - min) / actualStep));
- tintSeek(seek);
- seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
- @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
- if (!fromUser) return;
- float next = min + actualStep * progress;
- value.setText(formatFloat(next, node.unit));
- setValue(node.key, String.valueOf(next));
- }
- @Override public void onStartTrackingTouch(SeekBar seekBar) {}
- @Override public void onStopTrackingTouch(SeekBar seekBar) {}
- });
- parent.addView(seek, matchWidth());
- }
-
- private void buildRangeSlider(LinearLayout parent, RuntimeConfigSchema.Node node) {
- float min = parseFloat(node.minValue, 0f);
- float max = Math.max(min + 0.001f, parseFloat(node.maxValue, 100f));
- float requestedStep = parseFloat(node.step, 1f);
- if (requestedStep <= 0f) requestedStep = 1f;
- int steps = Math.max(1, Math.round((max - min) / requestedStep));
- float step = (max - min) / steps;
- List selected = parseArray(mod.getConfigValue(node.key, node.defaultValue));
- float low = selected.size() > 0 ? parseFloat(selected.get(0), min) : min;
- float high = selected.size() > 1 ? parseFloat(selected.get(1), max) : max;
- low = min + Math.round((clamp(low, min, max) - min) / step) * step;
- high = min + Math.round((clamp(high, min, max) - min) / step) * step;
- if (low > high) {
- float swap = low;
- low = high;
- high = swap;
- }
- TextView value = valueText(formatFloat(low, node.unit) + " – " + formatFloat(high, node.unit));
- parent.addView(value, alignEnd());
- RangeSlider slider = new RangeSlider(context);
- slider.setValueFrom(min);
- slider.setValueTo(max);
- slider.setStepSize(step);
- slider.setValues(clamp(low, min, max), clamp(high, min, max));
- slider.addOnChangeListener((s, changedValue, fromUser) -> {
- List values = s.getValues();
- if (values.size() < 2) return;
- value.setText(formatFloat(values.get(0), node.unit) + " – " + formatFloat(values.get(1), node.unit));
- if (fromUser) setValue(node.key, jsonArray(values.get(0), values.get(1)));
- });
- parent.addView(slider, matchWidth());
- }
-
- private void buildChoice(LinearLayout parent, RuntimeConfigSchema.Node node) {
- String current = mod.getConfigValue(node.key, node.defaultValue);
- // The style rule and the selected-option index are pure, so the view asks rather
- // than re-deriving them; a schema with no matching option reads index 0 on both paths.
- String style = RuntimeConfigControl.choiceStyle(node);
- if ("dropdown".equals(style)) {
- Spinner spinner = new Spinner(context);
- List labels = new ArrayList<>();
- for (int i = 0; i < node.options.size(); i++) labels.add(node.options.get(i).label);
- int selected = RuntimeConfigControl.choiceIndex(node.options, current);
- ArrayAdapter adapter = new ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, labels) {
- @Override public boolean isEnabled(int position) {
- return position >= 0 && position < node.options.size() && !node.options.get(position).disabled;
- }
- @Override public View getDropDownView(int position, View convertView, ViewGroup parentView) {
- View view = super.getDropDownView(position, convertView, parentView);
- boolean enabled = isEnabled(position);
- view.setEnabled(enabled);
- view.setAlpha(enabled ? 1f : 0.45f);
- return view;
- }
- };
- spinner.setAdapter(adapter);
- spinner.setSelection(selected, false);
- spinner.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
- String lastSelectedValue = current;
- @Override public void onItemSelected(android.widget.AdapterView> parentView, View view, int position, long id) {
- if (position >= 0 && position < node.options.size() && !node.options.get(position).disabled) {
- String value = node.options.get(position).value;
- // Initial callbacks are optional; ignore unchanged values, not the first event.
- if (value.equals(lastSelectedValue)) return;
- lastSelectedValue = value;
- setValue(node.key, value);
- }
- }
- @Override public void onNothingSelected(android.widget.AdapterView> parentView) {}
- });
- parent.addView(spinner, matchWidth());
- return;
- }
- if ("radio".equals(style)) {
- RadioGroup group = new RadioGroup(context);
- for (RuntimeConfigSchema.Option option : node.options) {
- RadioButton radio = new RadioButton(context);
- radio.setId(View.generateViewId());
- radio.setText(option.label);
- radio.setTextColor(TEXT_PRIMARY);
- radio.setTag(option.value);
- radio.setEnabled(!option.disabled);
- radio.setChecked(option.value.equals(current));
- group.addView(radio);
- }
- group.setOnCheckedChangeListener((g, id) -> {
- View view = g.findViewById(id);
- if (view != null && view.getTag() != null) setValue(node.key, String.valueOf(view.getTag()));
- });
- parent.addView(group, matchWidth());
- return;
- }
- ChipGroup group = new ChipGroup(context);
- group.setSingleSelection(true);
- group.setSelectionRequired(!node.options.isEmpty());
- for (RuntimeConfigSchema.Option option : node.options) {
- Chip chip = chip(option.label, option.value.equals(current));
- chip.setEnabled(!option.disabled);
- chip.setOnClickListener(v -> setValue(node.key, option.value));
- group.addView(chip);
- }
- parent.addView(group, matchWidth());
- }
-
- private void buildMultiChoice(LinearLayout parent, RuntimeConfigSchema.Node node) {
- LinkedHashSet