From 9ec16fb3129eaf1efba78cdb1a18b1d05ce113eb Mon Sep 17 00:00:00 2001 From: Lynxifyy <297587416+Lynxifyy@users.noreply.github.com> Date: Sat, 14 Nov 2026 06:19:58 +0000 Subject: [PATCH 1/7] feat(ui): one Caramel UI, a native ImGui mod menu, and honest frame-rate modules The launcher and the in-game menu now share one design language, taken from the CaramelClient-UI-pack: the Compose launcher uses ui.compose.caramel.* and the in-game menu is Dear ImGui on the game's GL context, themed from the same tokens in the sweetloader's pl/menu (pin bumped to the matching commit). The Compose/View mod-menu surface and the radial wheel are deleted, along with their layouts and tests: the native menu is the only mod menu now, and ModMenuNavLayoutTest pins that the old layouts stay gone. The FPS readout no longer passes one measurement off as another. FrameRateMeter measures the launcher's own Choreographer cadence and says so; the game's rate comes from the sweetloader render hook, and FpsReadout/ModStatsFormatter mark any value that is not the game's exact rate with a ~ and name its source. Frame generation and motion blur are added as present-frame effects: both blend the previous presented frame into the new one on the present boundary, neither presents an extra frame, and both are badged while the present hook is absent. Neither claims a frame-rate gain. --- .openhands/memory/MEMORY.md | 22 + AGENTS.md | 1 + .../client/core/backend/FrameTickSource.java | 13 + .../client/core/backend/GameBackend.java | 11 + .../client/core/backend/GameBridge.java | 19 + .../client/core/backend/GameFrameRate.java | 14 +- .../client/core/backend/HookAvailability.java | 8 +- .../core/backend/LauncherGameBackend.java | 12 + .../backend/SweetLoaderFrameTickSource.java | 5 + .../cosmetics/CosmeticsDiagnosticsReport.java | 7 - .../minecraft/FpsOptimizationService.java | 20 - .../mods/inbuilt/catalog/DebugScreenText.java | 25 +- .../core/mods/inbuilt/overlay/FpsReadout.java | 102 + .../core/mods/render/PresentFrameCatalog.java | 167 + .../core/mods/render/PresentFramePolicy.java | 117 + .../launcher/core/minecraft/Application.kt | 2 + .../core/minecraft/MinecraftActivity.kt | 30 +- .../mods/inbuilt/InbuiltModuleProvider.java | 1353 +------ .../core/mods/inbuilt/ModConfigSchema.java | 1176 +++++++ .../mods/inbuilt/RuntimeConfigControl.java | 155 - .../inbuilt/manager/InbuiltModManager.java | 21 - .../mods/inbuilt/model/ModAvailability.java | 14 + .../core/mods/inbuilt/model/ModIds.java | 33 +- .../core/mods/inbuilt/nativemod/FpsMod.java | 10 + .../overlay/BackdropRefreshSchedule.java | 10 +- .../mods/inbuilt/overlay/CosmeticsPanel.java | 2 +- .../inbuilt/overlay/CustomizeChooser.java | 161 - .../inbuilt/overlay/FpsDisplayOverlay.java | 61 +- .../mods/inbuilt/overlay/FrameRateMeter.java | 9 +- .../mods/inbuilt/overlay/GroupedMods.java | 41 - .../inbuilt/overlay/HudEditorController.java | 431 --- .../inbuilt/overlay/InGameMenuController.java | 402 +++ .../overlay/InbuiltOverlayManager.java | 50 +- .../mods/inbuilt/overlay/ModConfigView.java | 488 --- .../mods/inbuilt/overlay/ModMenuAction.java | 75 - .../inbuilt/overlay/ModMenuActionCatalog.java | 91 - .../overlay/ModMenuActionVisibility.java | 57 - .../mods/inbuilt/overlay/ModMenuAdapter.java | 1176 ------- .../mods/inbuilt/overlay/ModMenuButton.java | 154 +- .../overlay/ModMenuControllerSection.java | 337 -- .../mods/inbuilt/overlay/ModMenuGuard.java | 126 - .../overlay/ModMenuKeyboardSection.java | 253 -- .../mods/inbuilt/overlay/ModMenuMotion.java | 35 - .../inbuilt/overlay/ModMenuNavigation.java | 162 - .../mods/inbuilt/overlay/ModMenuOrdering.java | 119 - .../mods/inbuilt/overlay/ModMenuOverlay.java | 3123 ----------------- .../mods/inbuilt/overlay/ModMenuPage.java | 97 - .../mods/inbuilt/overlay/ModMenuPalette.java | 29 +- .../mods/inbuilt/overlay/ModMenuRail.java | 149 - .../inbuilt/overlay/ModMenuRowContent.java | 61 - .../mods/inbuilt/overlay/ModMenuRowKind.java | 53 - .../mods/inbuilt/overlay/ModMenuRowState.java | 61 - .../mods/inbuilt/overlay/ModMenuScreen.java | 155 - .../inbuilt/overlay/ModMenuSwitchView.java | 276 -- .../mods/inbuilt/overlay/ModMenuTheme.java | 4 - .../inbuilt/overlay/ModStatsFormatter.java | 13 +- .../mods/inbuilt/overlay/ModuleFilter.java | 31 - .../overlay/OverlayLayoutManagerDialog.java | 280 -- .../mods/inbuilt/overlay/RadialHeadSkin.java | 102 - .../inbuilt/overlay/RadialMenuOverlay.java | 589 ---- .../mods/inbuilt/overlay/RadialMenuView.java | 1925 ---------- .../RadialWheelAccessibilityHelper.java | 147 - .../inbuilt/overlay/RadialWheelLayout.java | 302 -- .../inbuilt/overlay/RadialWheelMaterial.java | 407 --- .../inbuilt/overlay/RadialWheelMotion.java | 233 -- .../inbuilt/overlay/RuntimeConfigView.java | 1168 ------ .../inbuilt/overlay/WheelBackdropCapture.java | 166 - .../core/mods/inbuilt/overlay/WheelEntry.java | 114 - .../inbuilt/overlay/WheelEntryCatalog.java | 160 - .../inbuilt/overlay/WheelGlassRenderer.java | 338 -- .../mods/inbuilt/overlay/WheelHoldPolicy.java | 68 - .../mods/inbuilt/overlay/WheelLayout.java | 117 - .../mods/inbuilt/overlay/WheelNavigation.java | 81 - .../overlay/WheelProfileManagerDialog.java | 516 --- .../inbuilt/overlay/WheelProfileStore.java | 355 -- .../core/mods/inbuilt/vip/ModCategory.java | 2 +- .../launcher/preloader/PreloaderInput.java | 88 +- .../launcher/preloader/PreloaderMenu.java | 142 + .../launcher/ui/activities/MainActivity.java | 57 +- .../launcher/ui/dialogs/ProfileSheet.java | 7 +- .../ui/components/PointerCursorRenderer.java | 10 +- .../client/ui/components/UiTokens.java | 81 +- .../client/ui/compose/modmenu/ModMenu.kt | 607 ---- .../client/ui/compose/modmenu/ModMenuHost.kt | 58 - .../client/ui/compose/modmenu/ModMenuState.kt | 114 - .../compose/modmenu/ModMenuStateBuilder.java | 102 - .../client/ui/compose/theme/GlowPalette.java | 61 +- app/src/main/res/drawable/bg_split_launch.xml | 80 + .../res/drawable/bg_split_launch_chevron.xml | 55 + .../main/res/layout/dialog_mod_menu_bind.xml | 62 - .../main/res/layout/include_launch_dock.xml | 102 +- .../main/res/layout/item_mod_menu_card.xml | 186 - .../main/res/layout/item_mod_menu_compact.xml | 166 - .../main/res/layout/overlay_hud_readout.xml | 17 + app/src/main/res/layout/overlay_mod_menu.xml | 1674 --------- .../main/res/layout/overlay_radial_menu.xml | 31 - .../res/layout/vip_controller_section.xml | 118 - .../main/res/layout/vip_keyboard_section.xml | 102 - app/src/main/res/values-es/strings.xml | 9 + app/src/main/res/values-fr/strings.xml | 9 + app/src/main/res/values-hi/strings.xml | 9 + app/src/main/res/values-id/strings.xml | 9 + app/src/main/res/values-ja/strings.xml | 9 + app/src/main/res/values-pt/strings.xml | 9 + app/src/main/res/values-ru/strings.xml | 9 + app/src/main/res/values-tr/strings.xml | 9 + app/src/main/res/values-vi/strings.xml | 9 + app/src/main/res/values-zh-rCN/strings.xml | 9 + app/src/main/res/values/colors.xml | 72 +- app/src/main/res/values/dimens.xml | 7 + app/src/main/res/values/strings.xml | 22 +- .../CosmeticsDiagnosticsReportTest.java | 10 - .../cosmetics/SkinListenerCoverageTest.java | 10 - .../mods/inbuilt/ModMenuNavigationTest.java | 76 - .../inbuilt/RuntimeConfigControlTest.java | 154 - .../inbuilt/catalog/DebugScreenTextTest.java | 26 + .../mods/inbuilt/model/NewModulesTest.java | 24 +- .../overlay/BackdropRefreshScheduleTest.java | 2 +- .../mods/inbuilt/overlay/FpsReadoutTest.java | 84 + .../inbuilt/overlay/HudReadoutLayoutTest.java | 51 +- .../inbuilt/overlay/ModMenuActionTest.java | 114 - .../overlay/ModMenuActionVisibilityTest.java | 64 - .../inbuilt/overlay/ModMenuGuardTest.java | 100 - .../inbuilt/overlay/ModMenuMotionTest.java | 34 - .../inbuilt/overlay/ModMenuOrderingTest.java | 128 - .../mods/inbuilt/overlay/ModMenuPageTest.java | 80 - .../inbuilt/overlay/ModMenuPaletteTest.java | 19 +- .../overlay/ModMenuPanelGlassTest.java | 41 - .../overlay/ModMenuRowContentTest.java | 92 - .../inbuilt/overlay/ModMenuRowKindTest.java | 40 - .../inbuilt/overlay/ModMenuRowStateTest.java | 77 - .../inbuilt/overlay/ModMenuSwitchTest.java | 143 - .../overlay/ModStatsFormatterTest.java | 7 + .../inbuilt/overlay/OverlayLayoutTest.java | 39 - .../overlay/RadialWheelLayoutTest.java | 286 -- .../overlay/RadialWheelMaterialTest.java | 336 -- .../overlay/RadialWheelMotionTest.java | 293 -- .../overlay/RadialWheelRoutingTest.java | 98 - .../overlay/WheelBackdropCaptureTest.java | 77 - .../mods/inbuilt/overlay/WheelEntryTest.java | 115 - .../inbuilt/overlay/WheelHoldPolicyTest.java | 68 - .../mods/inbuilt/overlay/WheelLayoutTest.java | 100 - .../inbuilt/overlay/WheelNavigationTest.java | 82 - .../overlay/WheelProfileStoreTest.java | 236 -- .../mods/render/PresentFrameModulesTest.java | 180 + .../inbuilt/overlay/ModMenuBottomNavTest.java | 62 - .../inbuilt/overlay/ModMenuNavLayoutTest.java | 54 +- .../ModUnavailableReasonResourcesTest.java | 47 +- .../ui/activities/LaunchDockStyleTest.java | 63 +- .../client/ui/components/UiTokensTest.java | 40 +- .../ui/compose/modmenu/ModMenuStateTest.kt | 128 - .../ui/compose/theme/GlowPaletteTest.java | 27 +- docs/MODULE_STATUS.md | 3 +- ...k-the-native-imgui-menu-and-fps-honesty.md | 26 + docs/sweetloader-jni-entry-points.md | 10 +- scripts/apply_translations.py | 1 + scripts/hygiene/file-sizes-baseline.txt | 4 +- scripts/hygiene/findviewbyid-baseline.txt | 10 +- scripts/hygiene/translation-baseline.txt | 10 - scripts/translations_present_frame.json | 92 + scripts/translations_priority_launch.json | 200 +- 161 files changed, 3958 insertions(+), 22915 deletions(-) create mode 100644 app/src/main/java/org/chimeramc/client/core/mods/inbuilt/overlay/FpsReadout.java create mode 100644 app/src/main/java/org/chimeramc/client/core/mods/render/PresentFrameCatalog.java create mode 100644 app/src/main/java/org/chimeramc/client/core/mods/render/PresentFramePolicy.java create mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/ModConfigSchema.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/RuntimeConfigControl.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/CustomizeChooser.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/GroupedMods.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/HudEditorController.java create mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModConfigView.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuAction.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuActionCatalog.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuActionVisibility.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuAdapter.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuControllerSection.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuGuard.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuKeyboardSection.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuMotion.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuNavigation.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuOrdering.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuOverlay.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuPage.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRail.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowContent.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowKind.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuRowState.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuScreen.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuSwitchView.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModuleFilter.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/OverlayLayoutManagerDialog.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialHeadSkin.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialMenuOverlay.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialMenuView.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelAccessibilityHelper.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelLayout.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelMaterial.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RadialWheelMotion.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/RuntimeConfigView.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelBackdropCapture.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelEntry.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelEntryCatalog.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelGlassRenderer.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelHoldPolicy.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelLayout.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelNavigation.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelProfileManagerDialog.java delete mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelProfileStore.java create mode 100644 app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java delete mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenu.kt delete mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuHost.kt delete mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuState.kt delete mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuStateBuilder.java create mode 100644 app/src/main/res/drawable/bg_split_launch.xml create mode 100644 app/src/main/res/drawable/bg_split_launch_chevron.xml delete mode 100644 app/src/main/res/layout/dialog_mod_menu_bind.xml delete mode 100644 app/src/main/res/layout/item_mod_menu_card.xml delete mode 100644 app/src/main/res/layout/item_mod_menu_compact.xml delete mode 100644 app/src/main/res/layout/overlay_mod_menu.xml delete mode 100644 app/src/main/res/layout/overlay_radial_menu.xml delete mode 100644 app/src/main/res/layout/vip_controller_section.xml delete mode 100644 app/src/main/res/layout/vip_keyboard_section.xml delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/ModMenuNavigationTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/RuntimeConfigControlTest.java create mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/FpsReadoutTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuActionTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuActionVisibilityTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuGuardTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuMotionTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuOrderingTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuPageTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuPanelGlassTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuRowContentTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuRowKindTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuRowStateTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/ModMenuSwitchTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/RadialWheelLayoutTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/RadialWheelMaterialTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/RadialWheelMotionTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/RadialWheelRoutingTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/WheelBackdropCaptureTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/WheelEntryTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/WheelHoldPolicyTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/WheelLayoutTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/WheelNavigationTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/core/mods/inbuilt/overlay/WheelProfileStoreTest.java create mode 100644 app/src/test/java/org/chimeramc/client/core/mods/render/PresentFrameModulesTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModMenuBottomNavTest.java delete mode 100644 app/src/test/java/org/chimeramc/client/ui/compose/modmenu/ModMenuStateTest.kt create mode 100644 docs/agents/one-ui-the-caramel-ui-pack-the-native-imgui-menu-and-fps-honesty.md create mode 100644 scripts/translations_present_frame.json diff --git a/.openhands/memory/MEMORY.md b/.openhands/memory/MEMORY.md index 8f1bff00..d2dcef7d 100644 --- a/.openhands/memory/MEMORY.md +++ b/.openhands/memory/MEMORY.md @@ -110,3 +110,25 @@ The repo was called **GlowberryClient** before; the package is `org.chimeramc.cl (diagnose with `actionlint`, not the API); and `set -o pipefail` + `cmd | grep -q` exits 74 (SIGPIPE) even on a match — dump to a file and grep the file. The Rust tree is rustfmt-normalized; keep it so. + +### Hygiene gates (the four that fail a local change) +- **`FileSizeBudgetTest`** (`scripts/hygiene/file-sizes-baseline.txt`, threshold 1500 lines): a listed + file may only **shrink**. `InbuiltModuleProvider.java` was at its 1765 baseline; the fix is to + **split**, not to edit the baseline. It was split into `ModConfigSchema.java` (config keys + + `ConfigEntry` list + `RuntimeConfigSchema` builders, 1175 lines) and the provider (748). The + provider keeps thin `static schemaTypeFor`/`configDescriptionRes` delegators so + `InbuiltModuleProviderTypeTest` (same package) still compiles; the moved members are package-private + and the provider calls them as `ModConfigSchema.x(...)`. +- **`ModuleDescriptionsTest`**: every `inbuilt_mod_*_desc` in `values/strings.xml` must be ≤ **80** + chars (target 60) and one sentence. A long "weighted by how fast the view is moving" tail is the + usual miss — keep it in the `*_scope_note` instead. +- **`TranslationCoverageTest` / `check_ui_hygiene.py` translation baseline**: a new string must be + translated in all 10 locales (`es fr hi id ja pt ru tr vi zh-rCN`) or marked `translatable="false"`. + Add a `scripts/translations_*.json` batch, register it in `BATCHES` in + `scripts/apply_translations.py`, then run the applier (it is idempotent and refuses a dropped `%s`). + After applying, `check_ui_hygiene.py` may report baseline entries that "no longer exist" — retire + them with `python3 scripts/check_ui_hygiene.py --update` (that also refreshes the findViewById + baseline, which *shrinks* when a `findViewById` is replaced by a ViewBinding read). +- **`NoSilentCatchTest`**: a `catch` that discards the throwable fails. Use + `AppLog.warnOnce(tag, key, message, e)`, even for an expected `UnsatisfiedLinkError` fail-closed path. +- Baseline format note: the baseline files live under **`scripts/hygiene/`**, not `scripts/`. diff --git a/AGENTS.md b/AGENTS.md index e9b3d7ae..c60dd7c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,3 +107,4 @@ file is missing from the index. - [World reconstruction — RE groundwork and the remaining wall](docs/agents/world-reconstruction-re-groundwork.md) - [MB shader loader version support (mtbinloader2 / libmtbinloader2.so)](docs/agents/mb-shader-loader-version-support.md) - [BerryAPI cosmetic C ABI (sweetloader mirror + launcher surface)](docs/agents/berryapi-cosmetic-c-abi.md) +- [One UI: the Caramel UI pack, the native ImGui menu, and FPS honesty](docs/agents/one-ui-the-caramel-ui-pack-the-native-imgui-menu-and-fps-honesty.md) diff --git a/app/src/main/java/org/chimeramc/client/core/backend/FrameTickSource.java b/app/src/main/java/org/chimeramc/client/core/backend/FrameTickSource.java index 43268e1b..b64a724b 100644 --- a/app/src/main/java/org/chimeramc/client/core/backend/FrameTickSource.java +++ b/app/src/main/java/org/chimeramc/client/core/backend/FrameTickSource.java @@ -20,4 +20,17 @@ public interface FrameTickSource { * this session (so the caller reports "unknown" rather than a fabricated rate). */ long renderTick(); + + /** + * Whether {@link #renderTick()} is the engine's exact present boundary rather than an inference. + * + *

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..8e591766 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/core/mods/inbuilt/overlay/FpsReadout.java @@ -0,0 +1,102 @@ +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 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; + } + + /** + * 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. */ + public String text() { + return ModStatsFormatter.fps(value, 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: + * + *

+ * + *

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

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: + * + *

+ */ +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..d60f1da4 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 @@ -56,7 +56,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 +180,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; @@ -676,25 +674,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/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/FpsDisplayOverlay.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/FpsDisplayOverlay.java index d86b3954..21ebc5e2 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; @@ -108,11 +111,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 +213,23 @@ 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; - } + // 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. A bare number therefore always means the game's exact frame rate. + int hostFps = fallbackMeter.isRunning() ? fallbackFps : 0; + FpsReadout readout = FpsReadout.of(nativeFps(), nativeExact(), hostFps); // 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) + // text is built and set only when the number (or its exactness) actually differs. + if (refreshGate.shouldRefresh(readout.text(), SystemClock.uptimeMillis())) { + HudThreshold.Level level = HudThreshold.levelForFps(readout.hasReading() ? readout.value : 0); + statsText.setText(readout.hasReading() + ? Integer.toString(readout.value) : 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. + if (hudApprox != null) { + hudApprox.setVisibility(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,7 +237,11 @@ private void updateDisplay() { hudAccent.setBackgroundColor(HudThreshold.colourFor(level)); } if (hudStats != null) { - String stats = statsEnabled ? fpsStatsText() : null; + // The avg/low come from the same Choreographer history the graph draws, which is the + // launcher's own cadence — not the game's render path. They are shown only when the + // headline number is that same host cadence, so the two lines can never disagree + // about what was measured. + String stats = statsEnabled && readout.isHostRefresh() ? fpsStatsText() : null; String cue = HudThreshold.cue(level); String line = stats == null ? "" : stats; if (!cue.isEmpty() && !line.isEmpty()) line = cue + " " + line; @@ -270,6 +283,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 +486,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/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/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/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/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/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 selected = new LinkedHashSet<>(parseArray(mod.getConfigValue(node.key, node.defaultValue))); - EditText search = node.searchable ? buildSearchField(parent) : null; - LinearLayout choices = new LinearLayout(context); - choices.setOrientation(LinearLayout.VERTICAL); - Runnable rebuild = () -> { - String query = search == null ? "" : search.getText().toString().trim().toLowerCase(Locale.US); - choices.removeAllViews(); - if ("checklist".equals(node.style)) { - for (RuntimeConfigSchema.Option option : node.options) { - String searchable = (option.label + " " + option.description).toLowerCase(Locale.US); - if (!query.isEmpty() && !searchable.contains(query)) continue; - CheckBox check = new CheckBox(context); - check.setText(option.label); - check.setTextColor(TEXT_PRIMARY); - check.setChecked(selected.contains(option.value)); - check.setEnabled(!option.disabled); - check.setOnCheckedChangeListener((button, checked) -> { - if (checked) selected.add(option.value); else selected.remove(option.value); - setValue(node.key, jsonArray(new ArrayList<>(selected))); - }); - choices.addView(check, matchWidth()); - } - } else { - ChipGroup group = new ChipGroup(context); - group.setSingleSelection(false); - for (RuntimeConfigSchema.Option option : node.options) { - String searchable = (option.label + " " + option.description).toLowerCase(Locale.US); - if (!query.isEmpty() && !searchable.contains(query)) continue; - Chip chip = chip(option.label, selected.contains(option.value)); - chip.setEnabled(!option.disabled); - chip.setOnCheckedChangeListener((button, checked) -> { - if (checked) selected.add(option.value); else selected.remove(option.value); - setValue(node.key, jsonArray(new ArrayList<>(selected))); - }); - group.addView(chip); - } - choices.addView(group, matchWidth()); - } - if (choices.getChildCount() == 0) { - TextView empty = new TextView(context); - empty.setText(query.isEmpty() ? "No options available" : "No matching options"); - empty.setTextColor(TEXT_SECONDARY); - empty.setTextSize(11); - choices.addView(empty); - } - }; - if (search != null) addSearchWatcher(search, rebuild); - rebuild.run(); - parent.addView(choices, matchWidth()); - } - - private void buildToggleGroup(LinearLayout parent, RuntimeConfigSchema.Node node) { - if ("checklist".equals(node.style)) { - LinearLayout list = new LinearLayout(context); - list.setOrientation(LinearLayout.VERTICAL); - for (RuntimeConfigSchema.Option option : node.options) { - String key = option.key.isEmpty() ? option.value : option.key; - CheckBox check = new CheckBox(context); - check.setText(option.label); - check.setTextColor(TEXT_PRIMARY); - check.setChecked(truthy(mod.getConfigValue(key, "false"))); - check.setEnabled(!option.disabled); - check.setOnCheckedChangeListener((button, checked) -> setValue(key, checked ? "true" : "false")); - list.addView(check, matchWidth()); - } - parent.addView(list, matchWidth()); - return; - } - ChipGroup group = new ChipGroup(context); - group.setSingleSelection(false); - for (RuntimeConfigSchema.Option option : node.options) { - String key = option.key.isEmpty() ? option.value : option.key; - Chip chip = chip(option.label, truthy(mod.getConfigValue(key, "false"))); - chip.setEnabled(!option.disabled); - chip.setOnCheckedChangeListener((button, checked) -> setValue(key, checked ? "true" : "false")); - group.addView(chip); - } - parent.addView(group, matchWidth()); - } - - private void buildOrderedList(LinearLayout parent, RuntimeConfigSchema.Node node) { - List selected = new ArrayList<>(parseArray(mod.getConfigValue(node.key, node.defaultValue))); - RecyclerView list = new RecyclerView(context); - list.setLayoutManager(new LinearLayoutManager(context)); - list.setNestedScrollingEnabled(false); - ChipGroup choices = new ChipGroup(context); - EditText search = node.searchable ? buildSearchField(parent) : null; - Runnable[] rebuildChoices = new Runnable[1]; - OrderedAdapter adapter = new OrderedAdapter(selected, node, () -> { - if (rebuildChoices[0] != null) rebuildChoices[0].run(); - updateOrderedListHeight(list, selected.size()); - }); - list.setAdapter(adapter); - updateOrderedListHeight(list, selected.size()); - parent.addView(list); - if (node.allowReorder) { - final boolean[] changed = {false}; - ItemTouchHelper helper = new ItemTouchHelper(new ItemTouchHelper.SimpleCallback( - ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0) { - @Override public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, - RecyclerView.ViewHolder target) { - int from = viewHolder.getBindingAdapterPosition(); - int to = target.getBindingAdapterPosition(); - if (from < 0 || to < 0 || from >= selected.size() || to >= selected.size()) return false; - Collections.swap(selected, from, to); - adapter.notifyItemMoved(from, to); - changed[0] = true; - return true; - } - @Override public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) { - super.clearView(recyclerView, viewHolder); - if (!changed[0]) return; - changed[0] = false; - setValue(node.key, jsonArray(selected)); - } - @Override public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {} - @Override public boolean isLongPressDragEnabled() { return true; } - }); - helper.attachToRecyclerView(list); - } - - TextView available = new TextView(context); - available.setText("Available"); - available.setTextColor(TEXT_SECONDARY); - available.setTextSize(11); - available.setPadding(0, dp(8), 0, dp(4)); - parent.addView(available); - rebuildChoices[0] = () -> { - String query = search == null ? "" : search.getText().toString().trim().toLowerCase(Locale.US); - choices.removeAllViews(); - for (RuntimeConfigSchema.Option option : node.options) { - if (selected.contains(option.value)) continue; - String searchable = (option.label + " " + option.description).toLowerCase(Locale.US); - if (!query.isEmpty() && !searchable.contains(query)) continue; - Chip chip = chip("+ " + option.label, false); - chip.setCheckable(false); - chip.setEnabled(!option.disabled); - chip.setOnClickListener(v -> { - if (selected.contains(option.value)) return; - selected.add(option.value); - adapter.notifyItemInserted(selected.size() - 1); - updateOrderedListHeight(list, selected.size()); - setValue(node.key, jsonArray(selected)); - rebuildChoices[0].run(); - }); - choices.addView(chip); - } - if (choices.getChildCount() == 0) { - TextView empty = new TextView(context); - empty.setText(query.isEmpty() ? "No more options to add" : "No matching options"); - empty.setTextColor(TEXT_SECONDARY); - empty.setTextSize(11); - choices.addView(empty); - } - }; - if (search != null) addSearchWatcher(search, rebuildChoices[0]); - rebuildChoices[0].run(); - parent.addView(choices, matchWidth()); - } - - private EditText buildSearchField(LinearLayout parent) { - EditText search = new EditText(context); - search.setSingleLine(true); - search.setHint("Search options"); - search.setTextColor(TEXT_PRIMARY); - search.setHintTextColor(TEXT_SECONDARY); - search.setTextSize(12); - LinearLayout.LayoutParams params = matchWidth(); - params.setMargins(0, 0, 0, dp(6)); - parent.addView(search, params); - return search; - } - - private void addSearchWatcher(EditText search, Runnable onChanged) { - search.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) { onChanged.run(); } - @Override public void afterTextChanged(Editable s) {} - }); - } - - private void updateOrderedListHeight(RecyclerView list, int count) { - int height = Math.min(dp(230), Math.max(dp(44), count * dp(44))); - ViewGroup.LayoutParams params = list.getLayoutParams(); - if (params == null) params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height); - params.width = ViewGroup.LayoutParams.MATCH_PARENT; - params.height = height; - list.setLayoutParams(params); - } - - private final class OrderedAdapter extends RecyclerView.Adapter { - private final List values; - private final RuntimeConfigSchema.Node node; - private final Runnable onListChanged; - OrderedAdapter(List values, RuntimeConfigSchema.Node node, Runnable onListChanged) { - this.values = values; - this.node = node; - this.onListChanged = onListChanged; - setHasStableIds(true); - } - @Override public long getItemId(int position) { return values.get(position).hashCode(); } - @Override public OrderedHolder onCreateViewHolder(ViewGroup parent, int viewType) { - LinearLayout row = new LinearLayout(context); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setPadding(dp(8), dp(3), dp(4), dp(3)); - TextView label = new TextView(context); - label.setTextColor(TEXT_PRIMARY); - label.setTextSize(12); - row.addView(label, new LinearLayout.LayoutParams(0, dp(38), 1f)); - Button remove = new Button(context); - // A vector close glyph, never the "×" text character: a text glyph changes shape with - // the device font and reads as a bullet or a math sign on some builds. The spec bans - // symbol glyphs in the UI, so the button carries the shared close icon. - remove.setText(""); - remove.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_close, 0, 0, 0); - remove.setCompoundDrawableTintList(ColorStateList.valueOf(TEXT_PRIMARY)); - remove.setMinWidth(0); - remove.setMinimumWidth(0); - remove.setPadding(dp(8), 0, dp(8), 0); - row.addView(remove, new LinearLayout.LayoutParams(dp(42), dp(38))); - return new OrderedHolder(row, label, remove); - } - @Override public void onBindViewHolder(OrderedHolder holder, int position) { - String value = values.get(position); - // The reorder affordance is a vector handle, not the "≡" character: a text glyph is a - // symbol, not an icon, and the spec bans it. It is appended to the row's own text view - // through a compound drawable so it keeps the row's baseline at any font scale. - holder.label.setText((position + 1) + ". " + optionLabel(node, value)); - if (node.allowReorder) { - holder.label.setCompoundDrawablesWithIntrinsicBounds(0, 0, R.drawable.ic_drag_handle, 0); - holder.label.setCompoundDrawablePadding(dp(6)); - holder.label.setCompoundDrawableTintList(ColorStateList.valueOf(TEXT_SECONDARY)); - } else { - holder.label.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); - } - holder.remove.setOnClickListener(v -> { - int pos = holder.getBindingAdapterPosition(); - if (pos < 0 || pos >= values.size()) return; - values.remove(pos); - notifyItemRemoved(pos); - notifyItemRangeChanged(pos, values.size() - pos); - setValue(node.key, jsonArray(values)); - if (onListChanged != null) onListChanged.run(); - }); - } - @Override public int getItemCount() { return values.size(); } - } - - private static final class OrderedHolder extends RecyclerView.ViewHolder { - final TextView label; - final Button remove; - OrderedHolder(View itemView, TextView label, Button remove) { - super(itemView); - this.label = label; - this.remove = remove; - } - } - - private void buildColor(LinearLayout parent, RuntimeConfigSchema.Node node) { - Button button = new Button(context); - String current = mod.getConfigValue(node.key, node.defaultValue); - if (current.isEmpty()) current = "#FFFFFFFF"; - button.setText(current); - button.setOnClickListener(v -> showColorDialog( - node, mod.getConfigValue(node.key, node.defaultValue), button)); - parent.addView(button, alignEnd()); - } - - private void showColorDialog(RuntimeConfigSchema.Node node, String initialValue, Button sourceButton) { - int initial; - // A corrupt stored literal reads as white through the pure parser instead of a - // swallowed exception, so the fallback is a rule with a test rather than an empty catch. - initial = HexColor.parseOrWhite(initialValue); - LinearLayout box = new LinearLayout(context); - box.setOrientation(LinearLayout.VERTICAL); - box.setPadding(dp(18), dp(6), dp(18), 0); - int[] color = {initial}; - addColorChannel(box, "R", Color.red(initial), value -> color[0] = Color.argb(Color.alpha(color[0]), value, Color.green(color[0]), Color.blue(color[0]))); - addColorChannel(box, "G", Color.green(initial), value -> color[0] = Color.argb(Color.alpha(color[0]), Color.red(color[0]), value, Color.blue(color[0]))); - addColorChannel(box, "B", Color.blue(initial), value -> color[0] = Color.argb(Color.alpha(color[0]), Color.red(color[0]), Color.green(color[0]), value)); - if (node.colorAlpha) addColorChannel(box, "A", Color.alpha(initial), value -> color[0] = Color.argb(value, Color.red(color[0]), Color.green(color[0]), Color.blue(color[0]))); - new AlertDialog.Builder(context) - .setTitle(node.title) - .setView(box) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, (dialog, which) -> { - String next = String.format(Locale.US, "#%08X", color[0]); - sourceButton.setText(next); - setValue(node.key, next); - }) - .show(); - } - - private interface IntConsumer { void accept(int value); } - private void addColorChannel(LinearLayout parent, String name, int initial, IntConsumer consumer) { - TextView label = new TextView(context); - label.setText(name + " " + initial); - label.setTextColor(TEXT_SECONDARY); - parent.addView(label); - SeekBar seek = new SeekBar(context); - seek.setMin(0); - seek.setMax(255); - seek.setProgress(initial); - tintSeek(seek); - seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - if (!fromUser) return; - label.setText(name + " " + progress); - consumer.accept(progress); - } - @Override public void onStartTrackingTouch(SeekBar seekBar) {} - @Override public void onStopTrackingTouch(SeekBar seekBar) {} - }); - parent.addView(seek, matchWidth()); - } - - private void buildKeybind(LinearLayout parent, RuntimeConfigSchema.Node node) { - int current = parseInt(mod.getConfigValue(node.key, node.defaultValue), 0); - Button button = new Button(context); - button.setText(keyName(current)); - button.setOnClickListener(v -> { - AlertDialog dialog = new AlertDialog.Builder(context) - .setTitle(node.title) - .setMessage("Press a key") - .setNegativeButton(android.R.string.cancel, null) - .create(); - dialog.setOnKeyListener((d, keyCode, event) -> { - if (event.getAction() != KeyEvent.ACTION_DOWN) return false; - if (keyCode == KeyEvent.KEYCODE_BACK) { dialog.dismiss(); return true; } - setValue(node.key, String.valueOf(keyCode)); - button.setText(keyName(keyCode)); - dialog.dismiss(); - return true; - }); - dialog.show(); - }); - parent.addView(button, alignEnd()); - } - - private void buildText(LinearLayout parent, RuntimeConfigSchema.Node node, boolean multiline) { - EditText input = new EditText(context); - input.setText(mod.getConfigValue(node.key, node.defaultValue)); - input.setTextColor(TEXT_PRIMARY); - input.setHintTextColor(TEXT_SECONDARY); - input.setHint(node.placeholder); - input.setSingleLine(!multiline); - input.setMinLines(multiline ? 3 : 1); - int maxLength = RuntimeConfigControl.maxLength(node); - if (maxLength > 0) { - input.setFilters(new android.text.InputFilter[]{ - new android.text.InputFilter.LengthFilter(maxLength)}); - } - input.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) {} - @Override public void afterTextChanged(Editable s) { setValue(node.key, s.toString()); } - }); - parent.addView(input, matchWidth()); - } - - private void buildButton(LinearLayout parent, RuntimeConfigSchema.Node node) { - Button button = new Button(context); - button.setText(node.title); - button.setTextColor(ACCENT); - button.setOnClickListener(v -> setValue(node.key, node.actionValue)); - parent.addView(button, matchWidth()); - } - - private Chip chip(String text, boolean checked) { - Chip chip = new Chip(context); - chip.setText(text); - chip.setCheckable(true); - chip.setChecked(checked); - chip.setTextColor(TEXT_PRIMARY); - chip.setChipBackgroundColor(new ColorStateList( - new int[][]{new int[]{android.R.attr.state_checked}, new int[]{}}, - new int[]{CARD_SELECTED, 0xFF2A2E32})); - chip.setChipStrokeWidth(1f); - chip.setChipStrokeColor(ColorStateList.valueOf(0xFF3C4349)); - return chip; - } - - private String optionLabel(RuntimeConfigSchema.Node node, String value) { - for (RuntimeConfigSchema.Option option : node.options) { - if (option.value.equals(value)) return option.label; - } - return value; - } - - private TextView valueText(String text) { - TextView view = new TextView(context); - view.setText(text); - view.setTextColor(ACCENT); - view.setTextSize(12); - return view; - } - - private LinearLayout.LayoutParams alignEnd() { - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.gravity = Gravity.END; - return params; - } - - private LinearLayout.LayoutParams matchWidth() { - return new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - } - - private void tintSeek(SeekBar seek) { - seek.setProgressTintList(ColorStateList.valueOf(ACCENT)); - seek.setThumbTintList(ColorStateList.valueOf(ACCENT)); - } - - private void tintSwitch(Switch control) { - int[][] states = {{android.R.attr.state_checked}, {}}; - control.setThumbTintList(new ColorStateList(states, new int[]{ACCENT, 0xFFA8B0B8})); - control.setTrackTintList(new ColorStateList(states, new int[]{0x886236E8, 0xFF343A40})); - } - - private void setEnabledRecursive(View view, boolean enabled) { - view.setEnabled(enabled); - if (view instanceof ViewGroup) { - ViewGroup group = (ViewGroup) view; - for (int i = 0; i < group.getChildCount(); i++) setEnabledRecursive(group.getChildAt(i), enabled); - } - } - - private GradientDrawable roundRect(int color, int radiusDp) { - GradientDrawable drawable = new GradientDrawable(); - drawable.setColor(color); - drawable.setCornerRadius(dp(radiusDp)); - return drawable; - } - - private int dp(int value) { - return Math.round(value * context.getResources().getDisplayMetrics().density); - } - - private static boolean truthy(String value) { - return "true".equalsIgnoreCase(value) || "1".equals(value) || "yes".equalsIgnoreCase(value) || "on".equalsIgnoreCase(value); - } - - private static boolean containsValue(String source, String expected) { - if (source == null) return false; - List values = parseArray(source); - if (!values.isEmpty()) return values.contains(expected); - return source.contains(expected); - } - - private static int parseInt(String value, int fallback) { - try { return Integer.parseInt(value); } catch (Exception ignored) { return fallback; } - } - - private static float parseFloat(String value, float fallback) { - try { return Float.parseFloat(value); } 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)); } - - private static String formatNumber(int value, String unit) { - return unit == null || unit.isEmpty() ? String.valueOf(value) : value + " " + unit; - } - - private static String formatFloat(float value, String unit) { - String number = String.format(Locale.US, "%.2f", value); - return unit == null || unit.isEmpty() ? number : number + " " + unit; - } - - private static List parseArray(String value) { - if (value == null || value.trim().isEmpty()) return new ArrayList<>(); - String trimmed = value.trim(); - try { - JSONArray array = new JSONArray(trimmed); - List result = new ArrayList<>(); - for (int i = 0; i < array.length(); i++) result.add(array.optString(i, "")); - result.removeIf(String::isEmpty); - return result; - } catch (Exception ignored) { - List result = new ArrayList<>(); - String separator = trimmed.contains("|") ? "\\|" : ","; - for (String part : trimmed.split(separator)) { - String clean = part.trim(); - if (!clean.isEmpty()) result.add(clean); - } - return result; - } - } - - private static String jsonArray(List values) { - JSONArray array = new JSONArray(); - for (String value : values) array.put(value); - return array.toString(); - } - - private static String jsonArray(float low, float high) { - JSONArray array = new JSONArray(); - array.put(Float.valueOf(low)); - array.put(Float.valueOf(high)); - return array.toString(); - } - - private static String keyName(int keyCode) { - if (keyCode == 0) return "None"; - String name = KeyEvent.keyCodeToString(keyCode); - return name.startsWith("KEYCODE_") ? name.substring(8).replace('_', ' ') : name; - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelBackdropCapture.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelBackdropCapture.java deleted file mode 100644 index 79e41242..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelBackdropCapture.java +++ /dev/null @@ -1,166 +0,0 @@ -package org.chimeramc.client.core.mods.inbuilt.overlay; - -import org.chimeramc.client.util.AppLog; - -import org.chimeramc.client.util.ApiLevels; - -import android.graphics.Bitmap; -import android.os.Build; -import android.os.Handler; -import android.os.Looper; -import android.view.PixelCopy; -import android.view.Window; -import org.chimeramc.client.util.MainThread; - -/** - * Captures a downscaled copy of the window behind the wheel, for the glass backdrop (R3, R12). - * - *

The capture is deliberately at a fraction of the window's size: the blurred sheet is soft - * anyway, so a 1/4-resolution source is visually identical and one sixteenth of the memory and copy - * cost. A failed capture (an unsupported API level, a detached window, an OEM that refuses - * {@code PixelCopy}) leaves the last good capture in place — the wheel then draws its solid-tier - * fill instead, which is why {@code RadialWheelMaterial} treats "no capture" as SOLID rather than as - * an error. - * - *

Every callback runs on the main thread and is guarded by {@code Throwable}: a capture is a - * cosmetic nicety and must never take the session down. - */ -public final class WheelBackdropCapture { - - /** The downscale divisor: a 1/4-resolution capture. */ - public static final int DOWNSCALE = 4; - /** The largest capture either dimension may be, so a huge tablet window cannot allocate a slab. */ - public static final int MAX_DIMENSION = 512; - - /** Receives a finished capture, or a failure. */ - public interface Callback { - void onCaptured(Bitmap capture); - void onFailed(); - } - - /** - * The main-thread handler, created lazily. - * - *

Deliberately not a field initialiser: constructing a {@code Handler} touches - * {@code Looper.getMainLooper()}, which does not exist in a JVM unit test, so an eager field - * would make this class un-constructible off-device and its pure size/schedule policy - * untestable. Only {@link #captureIfDue} needs the handler, so only it pays for it. - */ - private Handler mainHandler; - private final BackdropRefreshSchedule schedule = new BackdropRefreshSchedule(); - private Bitmap lastCapture; - private boolean captureFailed; - - /** True when the platform can capture at all ({@code PixelCopy} is API 24+, guarded for minSdk). */ - public static boolean isSupported() { - return ApiLevels.atLeastO(); - } - - /** The refresh schedule, so the caller can drive the rate limit (R3). */ - public BackdropRefreshSchedule schedule() { - return schedule; - } - - /** - * The capture size for a window size, in pixels (R3). - * - *

Pure so the downscale and the bound are testable without a window: a 1/4-resolution capture - * is visually identical to a full one for a soft sheet and a sixteenth of the copy cost, and the - * cap stops a very large tablet window allocating a slab. - */ - public static int captureDimension(int windowDimension) { - if (windowDimension <= 0) return 0; - return Math.max(1, Math.min(MAX_DIMENSION, windowDimension / DOWNSCALE)); - } - - /** The last successful capture, or null. Never recycled by the caller. */ - public Bitmap lastCapture() { - return lastCapture; - } - - /** Whether the most recent attempt failed; the material resolves to SOLID when true. */ - public boolean hasFailed() { - return captureFailed; - } - - /** True when the last attempt succeeded. */ - public boolean hasCapture() { - return lastCapture != null && !lastCapture.isRecycled(); - } - - /** - * Requests a capture if one is due at {@code nowMs}. - * - * @return true when a capture was started (the callback will fire) - */ - public boolean captureIfDue(Window window, long nowMs, int fps, Callback callback) { - if (!isSupported() || window == null) { - captureFailed = true; - return false; - } - if (!schedule.isDue(nowMs, fps)) return false; - - int width = 0; - int height = 0; - try { - width = window.getDecorView().getWidth(); - height = window.getDecorView().getHeight(); - } catch (Throwable ignored) { - AppLog.warnOnce("CaramelClient", "WheelBackdropCapture:109", "Ignored a failure at WheelBackdropCapture:109", ignored); - } - if (width <= 0 || height <= 0) return false; - - int targetW = captureDimension(width); - int targetH = captureDimension(height); - - final Bitmap buffer; - try { - buffer = Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888); - } catch (Throwable t) { - captureFailed = true; - return false; - } - try { - if (mainHandler == null) mainHandler = MainThread.handler(); - PixelCopy.request(window, buffer, result -> { - schedule.markCaptured(nowMs); - if (result == PixelCopy.SUCCESS) { - captureFailed = false; - // Hand the new capture over BEFORE recycling the previous one: the wheel now - // displays the capture on a layer (R3), so the old bitmap must not be recycled - // while that layer still points at it. - Bitmap previous = lastCapture; - lastCapture = buffer; - if (callback != null) callback.onCaptured(lastCapture); - recycleQuietly(previous); - } else { - captureFailed = true; - recycleQuietly(buffer); - if (callback != null) callback.onFailed(); - } - }, mainHandler); - } catch (Throwable t) { - captureFailed = true; - recycleQuietly(buffer); - return false; - } - return true; - } - - /** Releases the held capture and resets the schedule. */ - public void release() { - recycleQuietly(lastCapture); - lastCapture = null; - captureFailed = false; - schedule.reset(); - } - - private static void recycleQuietly(Bitmap bitmap) { - if (bitmap == null || bitmap.isRecycled()) return; - try { - bitmap.recycle(); - } catch (Throwable ignored) { - AppLog.warnOnce("CaramelClient", "WheelBackdropCapture:163", "Ignored a failure at WheelBackdropCapture:163", ignored); - } - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelEntry.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelEntry.java deleted file mode 100644 index 3faf7403..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelEntry.java +++ /dev/null @@ -1,114 +0,0 @@ -package org.chimeramc.client.core.mods.inbuilt.overlay; - -import java.util.ArrayList; -import java.util.List; - -/** - * A radial-wheel entry and the profiles that group them, as a serializable model. - * - *

The spec asks for a wheel the player can actually configure: multiple local profiles (one for - * survival, one for PvP), entries assigned to sectors with their own label, icon and colour, and - * nested sub-wheels so the action count is not capped at one ring. This is that model, and it is a - * plain POJO so Gson round-trips it and the rules around it stay unit-testable. - * - *

Two invariants are load-bearing and are enforced here rather than in the UI, because a profile - * that violates either draws sectors the player cannot hit: - * - *

    - *
  • an entry always has a label, so a sector is never blank;
  • - *
  • a profile never exceeds {@link WheelLayout#MAX_SECTORS} entries, because past that a sector - * is too thin to aim at.
  • - *
- */ -public final class WheelEntry { - - /** What selecting an entry does. */ - public enum Action { - /** Toggle a mod-menu module by id. */ - TOGGLE_MODULE, - /** Open the module list. */ - OPEN_MODS, - /** Open the cosmetics wardrobe. */ - OPEN_CLOSET, - /** Open the settings screen. */ - OPEN_SETTINGS, - /** Enter the HUD editor. */ - OPEN_HUD, - /** Open another wheel profile as a nested sub-wheel. */ - OPEN_SUBWHEEL, - /** Switch the active overlay layout. The target is the layout id. */ - SWITCH_LAYOUT, - } - - /** The entry's action. */ - public Action action = Action.TOGGLE_MODULE; - /** The module id for {@link Action#TOGGLE_MODULE}, the target profile id for - * {@link Action#OPEN_SUBWHEEL}, or the layout id for {@link Action#SWITCH_LAYOUT}. */ - public String target = ""; - /** The label drawn under the sector's icon. */ - public String label = ""; - /** The entry's own colour (ARGB), so a sector is recognisable at a glance; 0 means "use the - * accent". */ - public int color = 0; - - public WheelEntry() { - } - - public WheelEntry(Action action, String target, String label) { - this.action = action == null ? Action.TOGGLE_MODULE : action; - this.target = target == null ? "" : target; - this.label = normalizeLabel(label); - } - - /** A label is never empty, so a sector always has something to draw. */ - public static String normalizeLabel(String label) { - if (label == null || label.trim().isEmpty()) return "—"; - return label.trim(); - } - - /** The entry's effective colour: its own when set, else the supplied accent. */ - public int effectiveColor(int accent) { - return color == 0 ? accent : color; - } - - /** True when this entry opens another wheel rather than performing an action. */ - public boolean isSubWheel() { - return action == Action.OPEN_SUBWHEEL; - } - - /** - * One named wheel: a set of entries plus the id of the profile that opens it. - * - *

A profile is its own entity so a nested wheel is just another profile the parent points at; - * there is no separate "sub-wheel" type to keep in step. The root profile is the one the bind - * opens. - */ - public static final class Profile { - /** The stable id the parent's {@code OPEN_SUBWHEEL} entry refers to. */ - public String id = ""; - /** The display name (shown in the manager and on the hub while open). */ - public String name = ""; - /** The entries, in draw order; index 0 is the top sector and they run clockwise. */ - public List entries = new ArrayList<>(); - - public Profile() { - } - - public Profile(String id, String name) { - this.id = id == null ? "" : id; - this.name = name == null ? "" : name; - } - - /** The entries, never null and never longer than {@link WheelLayout#MAX_SECTORS}. */ - public List safeEntries() { - if (entries == null) return new ArrayList<>(); - if (entries.size() <= WheelLayout.MAX_SECTORS) return entries; - return new ArrayList<>(entries.subList(0, WheelLayout.MAX_SECTORS)); - } - - /** A profile with no entries cannot be shown, so it is treated as empty rather than drawn. */ - public boolean isEmpty() { - return safeEntries().isEmpty(); - } - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelEntryCatalog.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelEntryCatalog.java deleted file mode 100644 index e2681ee5..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelEntryCatalog.java +++ /dev/null @@ -1,160 +0,0 @@ -package org.chimeramc.client.core.mods.inbuilt.overlay; - -import org.chimeramc.client.core.mods.inbuilt.model.ModIds; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * What a wheel entry can be assigned to. - * - *

The manager's entry editor needs a fixed list to pick from. Without one it would have to ask - * the live module registry, which only exists during a session, so a profile could not be edited - * outside the game. This catalog is that list, built from the same {@link ModIds} constants the - * module registry uses, so an id offered here is always an id the wheel can resolve. - * - *

Pure (ids, labels and resource ids only), so the set and its labels are unit-testable. - */ -public final class WheelEntryCatalog { - - /** One assignable action. */ - public static final class Choice { - /** The {@link WheelEntry.Action} the entry will carry. */ - public final WheelEntry.Action action; - /** The module id for {@code TOGGLE_MODULE}, or the destination/profile id otherwise. */ - public final String target; - /** The label resource, or 0 when {@link #literalLabel} is used. */ - public final int labelRes; - /** A literal label for a module, which has no string resource of its own. */ - public final String literalLabel; - - Choice(WheelEntry.Action action, String target, int labelRes, String literalLabel) { - this.action = action; - this.target = target; - this.labelRes = labelRes; - this.literalLabel = literalLabel; - } - } - - private static final List CHOICES = Collections.unmodifiableList(build()); - - private WheelEntryCatalog() { - } - - private static List build() { - List out = new ArrayList<>(); - // The four destinations are the wheel's own navigation, so they come first. - out.add(new Choice(WheelEntry.Action.OPEN_MODS, "", 0, "Mods")); - out.add(new Choice(WheelEntry.Action.OPEN_CLOSET, "", 0, "Closet")); - out.add(new Choice(WheelEntry.Action.OPEN_HUD, "", 0, "HUD")); - out.add(new Choice(WheelEntry.Action.OPEN_SETTINGS, "", 0, "Settings")); - - // The toggles a player actually puts on a wheel: the PvP set and the readouts. - out.add(module(ModIds.SNAPLOOK)); - out.add(module(ModIds.CPS_DISPLAY)); - out.add(module(ModIds.HIT_REGISTRATION)); - out.add(module(ModIds.HIT_TIMING)); - out.add(module(ModIds.HITBOX)); - out.add(module(ModIds.ARMOR_HUD)); - out.add(module(ModIds.CRYSTAL_OPTIMIZER)); - out.add(module(ModIds.REACH_INDICATOR)); - out.add(module(ModIds.HIT_PREDICTION)); - out.add(module(ModIds.TRAJECTORY_PREDICTION)); - out.add(module(ModIds.KILL_EFFECTS)); - - out.add(module(ModIds.AUTO_SPRINT)); - out.add(module(ModIds.ZOOM)); - out.add(module(ModIds.GYRO)); - out.add(module(ModIds.FPS_DISPLAY)); - out.add(module(ModIds.FULLBRIGHT)); - out.add(module(ModIds.FREECAM)); - out.add(module(ModIds.VOICE_CHAT)); - out.add(module(ModIds.POJAV_CONTROLS)); - out.add(module(ModIds.HOTBAR_SLOT)); - out.add(module(ModIds.OFFHAND)); - out.add(module(ModIds.TOGGLE_HUD)); - return out; - } - - private static Choice module(String moduleId) { - return new Choice(WheelEntry.Action.TOGGLE_MODULE, moduleId, 0, prettyLabel(moduleId)); - } - - /** A module id turned into a readable sector label: {@code auto_sprint} becomes {@code Auto Sprint}. */ - public static String prettyLabel(String moduleId) { - if (moduleId == null || moduleId.isEmpty()) return "—"; - StringBuilder sb = new StringBuilder(); - boolean upper = true; - for (int i = 0; i < moduleId.length(); i++) { - char c = moduleId.charAt(i); - if (c == '_') { - upper = true; - sb.append(' '); - } else if (upper) { - sb.append(Character.toUpperCase(c)); - upper = false; - } else { - sb.append(c); - } - } - return sb.toString(); - } - - /** Every assignable choice, in display order. */ - public static List all() { - return CHOICES; - } - - /** - * The choices plus one sub-wheel entry per profile in {@code profiles} and one layout-switch - * entry per stored overlay layout. - * - *

A layout entry carries the layout's id in {@code target}, so the wheel can switch to it - * without a second lookup table. Layouts are offered after the sub-wheels so the wheel's own - * navigation stays at the top of the list. - */ - public static List forProfile(List profiles, String editingProfileId, - java.util.List layouts) { - List out = new ArrayList<>(forProfile(profiles, editingProfileId)); - if (layouts != null) { - for (OverlayLayout layout : layouts) { - if (layout == null || layout.id == null || layout.id.isEmpty()) continue; - String label = layout.name == null || layout.name.trim().isEmpty() - ? layout.id : layout.name.trim(); - out.add(new Choice(WheelEntry.Action.SWITCH_LAYOUT, layout.id, 0, label)); - } - } - return out; - } - - /** - * The choices plus one sub-wheel entry per profile in {@code profiles}. - * - *

A sub-wheel is offered only for a profile other than the one being edited, because an entry - * that opens its own wheel would recurse forever; {@link WheelProfileStore#setEntries} refuses - * it too, so the two agree. - */ - public static List forProfile(List profiles, String editingProfileId) { - List out = new ArrayList<>(CHOICES); - if (profiles == null) return out; - for (WheelEntry.Profile p : profiles) { - if (p == null || p.id == null || p.id.equals(editingProfileId)) continue; - out.add(new Choice(WheelEntry.Action.OPEN_SUBWHEEL, p.id, 0, - p.name == null || p.name.trim().isEmpty() ? p.id : p.name.trim())); - } - return out; - } - - /** The display label for a choice, resolved against the activity's resources. */ - public static String label(Choice choice, android.content.Context context) { - if (choice == null) return "—"; - if (choice.labelRes != 0 && context != null) return context.getString(choice.labelRes); - return choice.literalLabel == null ? "—" : choice.literalLabel; - } - - /** The entry a choice builds, with its label already set. */ - public static WheelEntry toEntry(Choice choice, android.content.Context context) { - return new WheelEntry(choice.action, choice.target, label(choice, context)); - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelGlassRenderer.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelGlassRenderer.java deleted file mode 100644 index 2e5a866d..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelGlassRenderer.java +++ /dev/null @@ -1,338 +0,0 @@ -package org.chimeramc.client.core.mods.inbuilt.overlay; - -import org.chimeramc.client.util.AppLog; - -import org.chimeramc.client.util.ApiLevels; - -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.Rect; -import android.os.Build; -import android.view.View; -import android.widget.ImageView; - -import org.chimeramc.client.ui.components.UiTokens; - -/** - * Paints the wheel's glass backdrop and applies the blur behind it (R2, R3, R12). - * - *

All of the heavy API work lives here and nowhere else. Two blur routes are supported because - * the honest answer differs by API level: - * - *

    - *
  • API 31+: the captured backdrop is shown on its own {@link ImageView} layer behind the - * wheel and blurred with a single {@code RenderEffect} on that layer. Blurring the backdrop - * layer rather than the wheel's own view is the whole point — a {@code RenderEffect} - * on the wheel would blur the wheel's own sectors and labels.
  • - *
  • API 28-30: no {@code RenderEffect} exists, so the captured backdrop is blurred with a - * downscale/upscale stack (blit to a small scratch, then stretch back with filtering), which - * costs two blits and adds no API surface.
  • - *
- * - *

Both routes are driven by the resolved {@link RadialWheelMaterial}, so the tier, the scrim - * floor and the thermal budget decide what happens; this class only carries out the decision. - */ -public final class WheelGlassRenderer { - - /** The denominator of the stack-blur's downscale. */ - public static final int STACK_BLUR_DIVISOR = 2; - - private final Paint fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint tintPaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint strokePaint = new Paint(Paint.ANTI_ALIAS_FLAG); - private final Paint bitmapPaint = new Paint(Paint.FILTER_BITMAP_FLAG); - private final Rect srcRect = new Rect(); - private final Rect dstRect = new Rect(); - private final Rect scratchDstRect = new Rect(); - private final Canvas scratchCanvas = new Canvas(); - - /** - * The stack-blur's intermediate bitmap, reused across frames. - * - *

Held rather than allocated per draw because {@link #drawStackBlur} runs inside - * {@code onDraw}: a per-frame allocation of a bitmap that size is a GC pause on the render path. - * It is rebuilt only when the capture's dimensions change. - */ - private Bitmap blurScratch; - - /** - * Shows the captured backdrop on its own layer and applies the blur (R3). - * - *

Called whenever the capture or the resolved material changes, never per frame. A material - * that does not blur (a solid tier, "reduce transparency", a thermal downgrade) hides the layer - * and clears the effect, so a tier change cannot leave a stale blur on screen. - */ - public void applyBackdrop(ImageView layer, RadialWheelMaterial.Resolved material, - Bitmap capture, float density) { - if (layer == null) return; - boolean usable = capture != null && !capture.isRecycled() - && RadialWheelMaterial.blurRoute(ApiLevels.sdkInt(), material) - == RadialWheelMaterial.BlurRoute.RENDER_EFFECT; - if (!usable) { - releaseBackdrop(layer); - return; - } - try { - layer.setVisibility(View.VISIBLE); - layer.setImageBitmap(capture); - Api31.applyBlur(layer, material.blurDp * Math.max(0.5f, density)); - } catch (Throwable ignored) { - AppLog.warnOnce("CaramelClient", "WheelGlassRenderer:81", "Ignored a failure at WheelGlassRenderer:81", ignored); - } - } - - /** Hides the backdrop layer, drops its bitmap and clears any effect (R3, R12). */ - public void releaseBackdrop(ImageView layer) { - if (layer != null) { - clearViewEffect(layer); - try { - layer.setImageDrawable(null); - layer.setVisibility(View.GONE); - } catch (Throwable ignored) { - AppLog.warnOnce("CaramelClient", "WheelGlassRenderer:93", "Ignored a failure at WheelGlassRenderer:93", ignored); - } - } - recycleScratch(); - } - - /** Clears any render effect on a view. Safe on every API level. */ - public void clearViewEffect(View target) { - if (target == null || ApiLevels.belowS()) return; - try { - target.setRenderEffect(null); - } catch (Throwable ignored) { - AppLog.warnOnce("CaramelClient", "WheelGlassRenderer:105", "Ignored a failure at WheelGlassRenderer:105", ignored); - } - } - - /** - * Applies the {@code RenderEffect} blur to a whole-window layer (M4). - * - *

The panel backdrop is a rectangle rather than the wheel's disc, so it needs the effect on - * a layer rather than the canvas route. Isolated here so the {@code RenderEffect} type is - * reached from exactly one place, behind this class's API guard. - */ - public void applyLayerBlur(ImageView layer, float radiusPx) { - if (layer == null || ApiLevels.belowS()) return; - Api31.applyBlur(layer, radiusPx); - } - - /** - * Produces a blurred copy of a whole capture for an {@link ImageView} layer (M4). - * - *

The API 28-30 counterpart of the {@code RenderEffect} route: the capture is shrunk and - * stretched back, and the filtered second blit is the blur. Used for the Mod Menu's - * panel backdrop, which is a rectangle rather than the wheel's disc, so it cannot go through - * {@link #drawBackdrop}. - * - *

Returns null when there is nothing to blur or the scratch cannot be allocated; the caller - * then leaves the layer hidden and the panel falls back to its solid fill. - */ - public Bitmap stackBlurCopy(Bitmap capture) { - if (capture == null || capture.isRecycled()) return null; - int w = capture.getWidth(); - int h = capture.getHeight(); - if (w <= 0 || h <= 0) return null; - int sw = Math.max(1, w / STACK_BLUR_DIVISOR); - int sh = Math.max(1, h / STACK_BLUR_DIVISOR); - Bitmap out; - try { - out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - } catch (Throwable ignored) { - return null; - } - Bitmap scratch = scratchFor(sw, sh); - if (scratch == null) { - recycleQuietly(out); - return null; - } - try { - Canvas canvas = new Canvas(out); - srcRect.set(0, 0, w, h); - scratchDstRect.set(0, 0, sw, sh); - scratchCanvas.setBitmap(scratch); - scratchCanvas.drawBitmap(capture, srcRect, scratchDstRect, bitmapPaint); - scratchCanvas.setBitmap(null); - // The filtered stretch back up is the blur. - srcRect.set(0, 0, sw, sh); - dstRect.set(0, 0, w, h); - canvas.drawBitmap(scratch, srcRect, dstRect, bitmapPaint); - } catch (Throwable ignored) { - recycleQuietly(out); - return null; - } - return out; - } - - private static void recycleQuietly(Bitmap bitmap) { - if (bitmap == null || bitmap.isRecycled()) return; - try { - bitmap.recycle(); - } catch (Throwable ignored) { - AppLog.warnOnce("CaramelClient", "WheelGlassRenderer:173", "Ignored a failure at WheelGlassRenderer:173", ignored); - } - } - - /** - * Draws the backdrop's non-blurred layers: the black scrim floor, the tier's dark fill and its - * white tint (R2). - * - *

The order is what makes the material read as glass: the scrim guarantees the labels have - * something to sit on over a bright frame, the fill sets the tier's solidity, and the tint is - * the light catching the sheet. On API 28-30 the stack-blurred capture is drawn underneath all - * three; on API 31+ the blurred {@code ImageView} layer is already behind the wheel, so this - * method only paints the sheet over it. - * - * @param cx,cy the wheel centre - * @param radius the radius of the backdrop disc - */ - public void drawBackdrop(Canvas canvas, RadialWheelMaterial.Resolved material, Bitmap capture, - float cx, float cy, float radius, float alpha) { - if (canvas == null || material == null) return; - float a = Math.max(0f, Math.min(1f, alpha)); - - // API 28-30 only: no RenderEffect exists, so the capture is blurred in the canvas. - if (capture != null && !capture.isRecycled() - && RadialWheelMaterial.blurRoute(ApiLevels.sdkInt(), material) - == RadialWheelMaterial.BlurRoute.STACK) { - drawStackBlur(canvas, capture, cx, cy, radius, a); - } - - int scrimAlpha = (int) (material.scrimAlpha * 255 * a); - if (scrimAlpha > 0) { - fillPaint.setStyle(Paint.Style.FILL); - fillPaint.setColor(UiTokens.withAlpha(0xFF000000, scrimAlpha)); - canvas.drawCircle(cx, cy, radius, fillPaint); - } - - int fillAlpha = (int) (material.fillAlpha * a); - if (fillAlpha > 0) { - fillPaint.setColor(UiTokens.withAlpha(UiTokens.BG_WINDOW, fillAlpha)); - canvas.drawCircle(cx, cy, radius, fillPaint); - } - - int tintAlpha = (int) (material.tint * 255 * a); - if (tintAlpha > 0) { - tintPaint.setColor(UiTokens.withAlpha(0xFFFFFFFF, tintAlpha)); - canvas.drawCircle(cx, cy, radius, tintPaint); - } - } - - /** - * Draws a blurred copy of the captured backdrop into the disc (the API 28-30 route). - * - *

A real two-pass stack blur: the capture is blitted down into a small scratch, then that - * scratch is stretched back over the disc with bilinear filtering. An earlier version mapped a - * sub-rectangle of the capture onto the disc, which showed the top-left quarter of the - * frame instead of the whole frame — a misread of "downscale" as "crop". The source rect is now - * the whole bitmap. - * - *

Every rect is a reused member and the scratch is cached, so the draw path allocates nothing - * after the first frame. - */ - private void drawStackBlur(Canvas canvas, Bitmap capture, float cx, float cy, float radius, - float alpha) { - int w = capture.getWidth(); - int h = capture.getHeight(); - if (w <= 0 || h <= 0) return; - dstRect.set(Math.round(cx - radius), Math.round(cy - radius), - Math.round(cx + radius), Math.round(cy + radius)); - int save = canvas.save(); - canvas.clipRect(dstRect); - bitmapPaint.setAlpha((int) (255 * alpha)); - - int sw = Math.max(1, w / STACK_BLUR_DIVISOR); - int sh = Math.max(1, h / STACK_BLUR_DIVISOR); - Bitmap scratch = scratchFor(sw, sh); - if (scratch == null) { - // No scratch available: fall back to the unblurred whole-frame blit, never a crop. - srcRect.set(0, 0, w, h); - canvas.drawBitmap(capture, srcRect, dstRect, bitmapPaint); - canvas.restoreToCount(save); - return; - } - - // Pass 1: shrink the whole frame into the scratch. - scratchDstRect.set(0, 0, sw, sh); - scratchCanvas.setBitmap(scratch); - srcRect.set(0, 0, w, h); - scratchCanvas.drawBitmap(capture, srcRect, scratchDstRect, bitmapPaint); - scratchCanvas.setBitmap(null); - - // Pass 2: stretch the shrunk copy over the disc — the filtered second blit is the blur. - srcRect.set(0, 0, sw, sh); - canvas.drawBitmap(scratch, srcRect, dstRect, bitmapPaint); - canvas.restoreToCount(save); - } - - private Bitmap scratchFor(int w, int h) { - if (w <= 0 || h <= 0) return null; - if (blurScratch != null && !blurScratch.isRecycled() - && blurScratch.getWidth() == w && blurScratch.getHeight() == h) { - return blurScratch; - } - recycleScratch(); - try { - blurScratch = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); - } catch (Throwable ignored) { - blurScratch = null; - } - return blurScratch; - } - - private void recycleScratch() { - if (blurScratch != null && !blurScratch.isRecycled()) { - try { - blurScratch.recycle(); - } catch (Throwable ignored) { - AppLog.warnOnce("CaramelClient", "WheelGlassRenderer:289", "Ignored a failure at WheelGlassRenderer:289", ignored); - } - } - blurScratch = null; - } - - /** Draws the hairline that defines a glass edge. */ - public void drawHairline(Canvas canvas, float cx, float cy, float radius, float alpha, - float density) { - if (canvas == null || alpha <= 0f) return; - strokePaint.setStyle(Paint.Style.STROKE); - strokePaint.setStrokeWidth(Math.max(1f, density)); - strokePaint.setColor(UiTokens.withAlpha(UiTokens.STROKE_HAIRLINE, - (int) (0x24 * Math.max(0f, Math.min(1f, alpha))))); - canvas.drawCircle(cx, cy, radius, strokePaint); - } - - /** - * The paint used for an outlined label: a stroke-only copy of the text paint (R10). - * - *

Returned rather than configured on a shared paint so the caller can draw the halo and the - * fill from the same text metrics without re-measuring. - */ - public Paint labelOutlinePaint(Paint textPaint, int color, float density) { - Paint outline = new Paint(textPaint); - outline.setStyle(Paint.Style.STROKE); - outline.setStrokeWidth(Math.max(1f, 2f * density)); - outline.setColor(color); - return outline; - } - - /** - * The {@code RenderEffect} blur, isolated in a holder (R12). - * - *

{@code RenderEffect} and {@code View#setRenderEffect} only exist on API 31, so the types - * live behind this {@code @TargetApi} nested class and are reached only after the caller's - * {@code SDK_INT} guard. Referencing them from the outer class's own fields or method signatures - * would fail class initialisation on API 28 — the documented crash shape for this project. - */ - @android.annotation.TargetApi(Build.VERSION_CODES.S) - private static final class Api31 { - static void applyBlur(View view, float radiusPx) { - float r = Math.max(0f, radiusPx); - view.setRenderEffect(r > 0f - ? android.graphics.RenderEffect.createBlurEffect(r, r, - android.graphics.Shader.TileMode.CLAMP) - : null); - } - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelHoldPolicy.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelHoldPolicy.java deleted file mode 100644 index 70f68ad3..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelHoldPolicy.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.chimeramc.client.core.mods.inbuilt.overlay; - -/** - * The radial wheel's open/confirm rule. - * - *

The wheel is specified as a hold-to-open menu: the bind press opens it, the player flicks or - * points at a sector while holding, and the release commits the selection. That is a different - * input shape from a toggle (press to open, press again to close), and both are legitimate, so the - * rule is one pure function rather than a branch buried in the key handler. - * - *

Android-free, so the decision table is unit-tested without a device — which matters because - * the failure mode (a wheel that will not open, or one that opens and immediately closes on the - * release of the same press) is only visible in play. - */ -public final class WheelHoldPolicy { - - /** What a bind press should do. */ - public enum OnPress { - /** Open the wheel and hold it until the release. */ - OPEN_HOLD, - /** Open it and keep it open (toggle-style open). */ - OPEN_TOGGLE, - /** It is already open, so this press closes it. */ - CLOSE, - /** Nothing to do. */ - IGNORE, - } - - /** What a bind release should do. */ - public enum OnRelease { - /** Commit whatever is highlighted (or dismiss when nothing is). */ - CONFIRM, - /** Close without acting. */ - DISMISS, - /** Nothing to do. */ - IGNORE, - } - - private WheelHoldPolicy() { - } - - /** - * @param holdToOpen the player's preference (hold-to-open when true, toggle when false) - * @param isShowing whether the wheel is already open - * @param openedByHold whether the currently open wheel was opened by this same hold gesture - */ - public static OnPress onPress(boolean holdToOpen, boolean isShowing, boolean openedByHold) { - if (!holdToOpen) { - return isShowing ? OnPress.CLOSE : OnPress.OPEN_TOGGLE; - } - // A hold gesture opens on the press. If the wheel is already open it was opened by an - // earlier gesture (or a launcher tap), so this press closes it — otherwise the wheel could - // never be dismissed with the bind. - return isShowing ? OnPress.CLOSE : OnPress.OPEN_HOLD; - } - - /** - * @param holdToOpen the player's preference - * @param isShowing whether the wheel is open at the release - * @param openedByHold whether the open wheel belongs to this hold gesture - * @param hasSelection whether a sector or entry is highlighted - */ - public static OnRelease onRelease(boolean holdToOpen, boolean isShowing, boolean openedByHold, - boolean hasSelection) { - if (!holdToOpen || !isShowing || !openedByHold) return OnRelease.IGNORE; - return hasSelection ? OnRelease.CONFIRM : OnRelease.DISMISS; - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelLayout.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelLayout.java deleted file mode 100644 index a59ff63e..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelLayout.java +++ /dev/null @@ -1,117 +0,0 @@ -package org.chimeramc.client.core.mods.inbuilt.overlay; - -/** - * The geometry of an N-sector radial wheel, as pure arithmetic. - * - *

{@link RadialWheelLayout} is the fixed four-sector wheel the Mod Menu ships with; this is the - * generalisation the profile-driven wheel needs, because a profile may carry anywhere from one to - * {@link #MAX_SECTORS} entries and the sector count changes with it. Both live here because the - * hit-testing rules are the same shape and keeping a second copy would let the drawn sectors and the - * touched sectors drift — the class of bug a screenshot cannot catch. - * - *

The sector boundary is a parallel-sided linear gap, not a fixed angular gap: a fixed - * angle widens with radius and reads as a cheap pie chart, so the gap is expressed in pixels and - * converted to an angle at the pointer's own radius. That conversion is why the hit test needs the - * radius and not just the angle. - * - *

Screen space: x grows right, y grows down, so sector 0 is centred at the top (-90 degrees) and - * the sectors run clockwise. Pure and Android-free, so every rule below is unit-tested without a - * device. - */ -public final class WheelLayout { - - /** The most entries a single wheel may hold; beyond this a sector is too thin to hit. */ - public static final int MAX_SECTORS = 8; - - /** The constant linear gap between neighbouring sectors, in dp. */ - public static final float GAP_DP = 6f; - - /** The angle at which sector 0 is centred: straight up, in screen space (y down). */ - public static final double FIRST_SECTOR_DEGREES = -90.0; - - private WheelLayout() { - } - - /** - * The angular width of one sector for a sector count. - * - *

One sector owns the whole circle rather than a slice of it: a single-entry wheel is a ring, - * not a 360-degree sector with a seam, so the caller draws a full ring and the hit test always - * returns that one sector. - */ - public static double sectorSpanDegrees(int sectorCount) { - int count = clampSectorCount(sectorCount); - return 360.0 / count; - } - - /** Clamps a sector count into {@code [1, MAX_SECTORS]}. */ - public static int clampSectorCount(int sectorCount) { - return Math.max(1, Math.min(MAX_SECTORS, sectorCount)); - } - - /** The centre angle of one sector, in screen space (y down), in degrees. */ - public static double sectorCenterDegrees(int index, int sectorCount) { - int count = clampSectorCount(sectorCount); - int i = Math.floorMod(index, count); - return FIRST_SECTOR_DEGREES + i * sectorSpanDegrees(count); - } - - /** - * 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, which is the - * cheap pie-chart look. The half-gap therefore shrinks as the radius grows. - */ - public static double angularHalfGapDegrees(float radius, float gapPx) { - if (radius <= 1f) return 180.0; - return Math.toDegrees((gapPx / 2.0) / radius); - } - - /** - * The sector index under a pointer, or {@code -1} for the hub, the gap or outside the wheel. - * - *

The hub is the centre disc (the player's head), so a pointer inside it is never a sector. - * A pointer inside the parallel-sided gap between two sectors is deliberately {@code -1} rather - * than snapped to the nearer one: a boundary touch is ambiguous, and guessing made a selection - * the player did not aim at. - * - * @param dx,dy pointer minus the wheel centre - * @param innerRadius the ring's inner edge; inside this is the hub - * @param outerRadius the ring's outer edge; outside this is off-wheel - * @param gapPx the constant linear gap between sectors - * @param sectorCount how many sectors the wheel is divided into - * @return the sector index, or -1 - */ - public static int sectorAt(float dx, float dy, float innerRadius, float outerRadius, - float gapPx, int sectorCount) { - int count = clampSectorCount(sectorCount); - float distance = (float) Math.hypot(dx, dy); - if (distance <= innerRadius) return -1; - if (distance > outerRadius) return -1; - // A one-sector wheel is a ring with no internal boundary, so the whole band is that sector. - if (count == 1) return 0; - - double span = sectorSpanDegrees(count); - double deg = Math.toDegrees(Math.atan2(dy, dx)); - // Shift so sector 0's centre is at the origin, then wrap into [0, 360). - double relative = wrap360(deg - FIRST_SECTOR_DEGREES); - // The distance to the nearest boundary. Sector boundaries sit at relative == halfSpan - // (mod span), so measure from there and fold into [0, span/2]. - double halfSpan = span / 2.0; - double fromBoundary = wrap360(relative - halfSpan) % span; - if (fromBoundary > halfSpan) fromBoundary = span - fromBoundary; - double halfGap = angularHalfGapDegrees(distance, gapPx); - if (fromBoundary < halfGap) return -1; - - // Sector i is centred at relative == i*span, so the nearest centre wins. - long index = Math.round(relative / span); - return (int) Math.floorMod(index, (long) count); - } - - /** Wraps an angle into {@code [0, 360)}. */ - public static double wrap360(double deg) { - double d = deg % 360.0; - if (d < 0.0) d += 360.0; - return d; - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelNavigation.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelNavigation.java deleted file mode 100644 index 9003d89d..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelNavigation.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.chimeramc.client.core.mods.inbuilt.overlay; - -import java.util.ArrayList; -import java.util.List; - -/** - * The open sub-wheel path, as a pure stack. - * - *

The spec's "nested sub-wheels for an unlimited number of actions" is a navigation problem, not - * a drawing one: opening a sub-wheel pushes a profile, and backing out pops it. Modelling it as a - * stack is what makes the depth, the back-out and the "we are back at the root" question testable - * without a device. - * - *

The depth is bounded by {@link #MAX_DEPTH}. A profile may legitimately point back at an - * ancestor (the built-in PvP wheel has a "Back" entry to the main wheel), so a cycle is not an error - * — but an unbounded stack would let a repeated cycle grow without limit, so the bound is what - * keeps a cycle from becoming a leak. - */ -public final class WheelNavigation { - - /** The most sub-wheels that may be open at once. */ - public static final int MAX_DEPTH = 8; - - private final List stack = new ArrayList<>(); - - public WheelNavigation() { - } - - /** Starts at a root profile id. A null/blank id is ignored, so the stack is never seeded blank. */ - public WheelNavigation(String rootId) { - reset(rootId); - } - - /** Clears the stack and seeds it with a root profile id. */ - public void reset(String rootId) { - stack.clear(); - if (rootId != null && !rootId.trim().isEmpty()) stack.add(rootId); - } - - /** - * Opens a sub-wheel. - * - * @return true when it was pushed; false when the id is blank, the stack is already at - * {@link #MAX_DEPTH}, or the id is already on the stack (pushing it would deepen a - * cycle the player did not ask for — the wheel's own "Back" entry is how they leave) - */ - public boolean push(String id) { - if (id == null || id.trim().isEmpty()) return false; - if (stack.size() >= MAX_DEPTH) return false; - if (stack.contains(id)) return false; - stack.add(id); - return true; - } - - /** - * Backs out one level. - * - * @return true when a level was popped; false when already at the root, which is what the caller - * uses to close the wheel instead - */ - public boolean pop() { - if (stack.size() <= 1) return false; - stack.remove(stack.size() - 1); - return true; - } - - /** The id of the wheel currently open, or "" when the stack is empty. */ - public String currentId() { - return stack.isEmpty() ? "" : stack.get(stack.size() - 1); - } - - /** How many wheels are open, root included; 0 when the stack is empty. */ - public int depth() { - return stack.size(); - } - - /** True while the root wheel is the open one. */ - public boolean atRoot() { - return stack.size() <= 1; - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelProfileManagerDialog.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelProfileManagerDialog.java deleted file mode 100644 index 919c0c56..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelProfileManagerDialog.java +++ /dev/null @@ -1,516 +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.ArrayList; -import java.util.List; -import java.util.function.Consumer; - -/** - * The radial wheel's profile manager. - * - *

The spec asks for a built-in manager that can create, rename, reorder and delete wheel - * profiles. {@link WheelProfileStore} owns every one of those operations but had no screen driving - * them, so the store's create/rename/reorder/delete were unreachable: the wheel could only ever - * show the built-in default set. This dialog is that screen. - * - *

It writes straight through the store and re-reads the list after each change, so the manager - * and the wheel cannot disagree: the store is the one source of truth and this is only a view of - * it. The dialog is opened from the mod menu, which is where the wheel itself is configured. - */ -final class WheelProfileManagerDialog { - - private WheelProfileManagerDialog() { - } - - static void show(Activity activity, ModMenuTheme theme) { - if (activity == null) return; - final WheelProfileStore store = new WheelProfileStore(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.wheel_manager_title); - title.setTextColor(theme.textPrimary()); - title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f); - title.setTypeface(null, Typeface.BOLD); - root.addView(title); - - // The scroll region is what makes the list survive more profiles than fit on screen. - 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); - - // Rebuilding the whole list is correct here: the store reloads on every mutation, and the - // list is short enough that a diff would be more code than it saves. - final Runnable rebuild = new Runnable() { - @Override - public void run() { - list.removeAllViews(); - List profiles = store.load(); - String rootId = store.getRootId(); - for (int i = 0; i < profiles.size(); i++) { - WheelEntry.Profile profile = profiles.get(i); - if (profile == null) continue; - addProfileRow(activity, theme, list, store, this, profile, - i == 0, i == profiles.size() - 1, profile.id.equals(rootId)); - } - if (profiles.size() <= 1) { - TextView note = new TextView(activity); - note.setText(R.string.wheel_manager_last_note); - note.setTextColor(theme.textTertiary()); - note.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f); - LinearLayout.LayoutParams p = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - p.topMargin = dp(activity, 8); - list.addView(note, p); - } - } - }; - 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 add = sheetButton(activity, theme, R.string.wheel_manager_add, false); - add.setOnClickListener(v -> promptForName(activity, theme, R.string.wheel_manager_add_title, - "", name -> { - store.create(name); - rebuild.run(); - })); - buttons.addView(add); - - 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 addProfileRow(Activity activity, ModMenuTheme theme, LinearLayout list, - WheelProfileStore store, Runnable rebuild, - WheelEntry.Profile profile, boolean first, boolean last, - boolean isRoot) { - LinearLayout row = new LinearLayout(activity); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setPadding(dp(activity, 12), dp(activity, 8), dp(activity, 8), dp(activity, 8)); - row.setBackground(ReplayStyle.rounded(theme.disabledCardColor(), 12f, activity)); - - LinearLayout identity = new LinearLayout(activity); - identity.setOrientation(LinearLayout.VERTICAL); - TextView name = new TextView(activity); - name.setText(profile.name == null ? profile.id : profile.name); - name.setTextColor(theme.textPrimary()); - name.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f); - identity.addView(name); - - TextView meta = new TextView(activity); - // The root wheel is the one the bind opens; showing it is what makes "Set as root" legible. - meta.setText(isRoot - ? activity.getString(R.string.wheel_manager_root_label) - : activity.getString(R.string.wheel_manager_entry_count, - profile.safeEntries().size())); - meta.setTextColor(isRoot ? theme.accent() : theme.textTertiary()); - meta.setTextSize(TypedValue.COMPLEX_UNIT_SP, 10f); - identity.addView(meta); - row.addView(identity, new LinearLayout.LayoutParams(0, - ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); - - // Edit the entries: which action sits in which sector, and the sub-wheel links. - row.addView(iconButton(activity, theme, R.drawable.ic_modules, v -> - showEntryEditor(activity, theme, store, rebuild, profile.id))); - - // Set as root: only meaningful for a non-root profile. - if (!isRoot) { - row.addView(iconButton(activity, theme, R.drawable.ic_check, v -> { - store.setRootId(profile.id); - rebuild.run(); - })); - } - row.addView(iconButton(activity, theme, R.drawable.ic_edit, v -> - promptForName(activity, theme, R.string.wheel_manager_rename_title, - profile.name, renamed -> { - store.rename(profile.id, renamed); - rebuild.run(); - }))); - if (!first) { - row.addView(iconButton(activity, theme, R.drawable.ic_arrow_up, v -> { - store.reorder(profile.id, -1); - rebuild.run(); - })); - } - if (!last) { - row.addView(iconButton(activity, theme, R.drawable.ic_arrow_down, v -> { - store.reorder(profile.id, 1); - rebuild.run(); - })); - } - if (!isRoot) { - row.addView(iconButton(activity, theme, R.drawable.ic_delete, v -> { - store.delete(profile.id, store.getRootId()); - rebuild.run(); - })); - } - - DynamicAnim.applyPressScale(row); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.topMargin = dp(activity, 6); - list.addView(row, params); - } - - /** - * The entry editor for one wheel. - * - *

This is the other half of "create, rename, reorder, and delete profiles and entries": the - * profile list decides which wheels exist, and this decides what is on each one. Every change - * goes through {@link WheelProfileStore#setEntries} (via the add/remove/reorder helpers), so the - * recursion and unknown-target refusals live in the store rather than here. - */ - private static void showEntryEditor(Activity activity, ModMenuTheme theme, - WheelProfileStore store, Runnable onClose, - String profileId) { - 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.wheel_manager_entries_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[1]; - rebuild[0] = () -> { - list.removeAllViews(); - WheelEntry.Profile profile = findProfile(store, profileId); - List entries = profile == null - ? new ArrayList<>() : profile.safeEntries(); - if (entries.isEmpty()) { - TextView empty = new TextView(activity); - empty.setText(R.string.wheel_manager_no_entries); - empty.setTextColor(theme.textTertiary()); - empty.setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f); - list.addView(empty); - } - for (int i = 0; i < entries.size(); i++) { - final int index = i; - WheelEntry entry = entries.get(i); - LinearLayout row = new LinearLayout(activity); - row.setOrientation(LinearLayout.HORIZONTAL); - row.setGravity(Gravity.CENTER_VERTICAL); - row.setPadding(dp(activity, 12), dp(activity, 8), dp(activity, 8), dp(activity, 8)); - row.setBackground(ReplayStyle.rounded(theme.disabledCardColor(), 12f, activity)); - - TextView label = new TextView(activity); - label.setText(entryLabel(entry)); - label.setTextColor(theme.textPrimary()); - label.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f); - row.addView(label, new LinearLayout.LayoutParams(0, - ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); - - if (i > 0) { - row.addView(iconButton(activity, theme, R.drawable.ic_arrow_up, v -> { - store.reorderEntry(profileId, index, -1); - rebuild[0].run(); - })); - } - if (i < entries.size() - 1) { - row.addView(iconButton(activity, theme, R.drawable.ic_arrow_down, v -> { - store.reorderEntry(profileId, index, 1); - rebuild[0].run(); - })); - } - row.addView(iconButton(activity, theme, R.drawable.ic_delete, v -> { - store.removeEntry(profileId, index); - rebuild[0].run(); - })); - - DynamicAnim.applyPressScale(row); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT); - params.topMargin = dp(activity, 6); - list.addView(row, params); - } - }; - rebuild[0].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 add = sheetButton(activity, theme, R.string.wheel_manager_add_entry, false); - add.setOnClickListener(v -> showEntryPicker(activity, theme, store, profileId, - () -> rebuild[0].run())); - buttons.addView(add); - - TextView done = sheetButton(activity, theme, R.string.wheel_manager_done, true); - done.setOnClickListener(v -> { - dialog.dismiss(); - if (onClose != null) onClose.run(); - }); - 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(); - } - - /** The list of assignable actions; picking one appends it to the wheel. */ - private static void showEntryPicker(Activity activity, ModMenuTheme theme, - WheelProfileStore store, String profileId, - Runnable onPicked) { - List choices = WheelEntryCatalog.forProfile( - store.load(), profileId, new OverlayLayoutStore(activity).load()); - 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.wheel_manager_pick_action); - 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, 320)); - scrollParams.topMargin = dp(activity, 10); - root.addView(scroll, scrollParams); - - for (WheelEntryCatalog.Choice choice : choices) { - TextView option = new TextView(activity); - option.setText(WheelEntryCatalog.label(choice, activity)); - option.setTextColor(theme.textPrimary()); - option.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f); - option.setPadding(dp(activity, 12), dp(activity, 12), dp(activity, 12), dp(activity, 12)); - option.setBackground(ReplayStyle.rippled( - ReplayStyle.rounded(theme.disabledCardColor(), 12f, activity), - theme.accentFill(60), activity)); - option.setOnClickListener(v -> { - store.addEntry(profileId, WheelEntryCatalog.toEntry(choice, activity)); - dialog.dismiss(); - if (onPicked != null) onPicked.run(); - }); - DynamicAnim.applyPressScale(option); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.topMargin = dp(activity, 6); - list.addView(option, params); - } - - dialog.setContentView(root); - Window window = dialog.getWindow(); - if (window != null) { - window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - window.setLayout(ReplayStyle.dpInt(activity, 340f), - ViewGroup.LayoutParams.WRAP_CONTENT); - window.setGravity(Gravity.CENTER); - window.setDimAmount(0.6f); - } - DynamicAnim.animateDialogShow(root); - dialog.show(); - } - - private static WheelEntry.Profile findProfile(WheelProfileStore store, String profileId) { - for (WheelEntry.Profile p : store.load()) { - if (p != null && p.id != null && p.id.equals(profileId)) return p; - } - return null; - } - - /** The label shown for an entry row, falling back to the action name when it has none. */ - private static String entryLabel(WheelEntry entry) { - if (entry == null) return "—"; - if (entry.label != null && !entry.label.trim().isEmpty()) return entry.label.trim(); - return entry.action == null ? "—" : entry.action.name(); - } - - /** A compact icon button for a row action. */ - private static TextView iconButton(Activity activity, ModMenuTheme theme, int iconRes, - View.OnClickListener click) { - TextView button = new TextView(activity); - button.setBackground(ReplayStyle.rippled( - ReplayStyle.rounded(theme.panelSurface(), 10f, activity), - theme.accentFill(60), activity)); - button.setCompoundDrawablesRelativeWithIntrinsicBounds(iconRes, 0, 0, 0); - button.setPadding(dp(activity, 10), dp(activity, 10), dp(activity, 10), dp(activity, 10)); - button.setOnClickListener(click); - DynamicAnim.applyPressScale(button); - LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); - params.setMarginStart(dp(activity, 4)); - button.setLayoutParams(params); - return button; - } - - /** Asks for a name, then runs the action with it. An empty name is refused. */ - 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, 12), dp(activity, 7), dp(activity, 12), dp(activity, 7)); - 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/WheelProfileStore.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelProfileStore.java deleted file mode 100644 index cda00ebe..00000000 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/WheelProfileStore.java +++ /dev/null @@ -1,355 +0,0 @@ -package org.chimeramc.client.core.mods.inbuilt.overlay; - -import android.content.Context; -import android.content.SharedPreferences; - -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; - -import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; - -/** - * Persists the radial wheel's profiles and the active one. - * - *

Stored in SharedPreferences as JSON, the same shape {@code ControllerProfileManager} uses, so - * there is one persistence idiom for a user-authored list. A profile is only ever referenced by id - * (a parent's {@code OPEN_SUBWHEEL} entry names it), so the store owns the id -> profile lookup and - * a dangling reference degrades to "not found" rather than a crash. - * - *

The store never throws: a corrupt blob reads as the built-in default profile set, because a - * wheel that cannot load must still be usable. - */ -public final class WheelProfileStore { - - private static final String PREFS_NAME = "radial_wheel_profiles"; - private static final String KEY_PROFILES = "profiles"; - private static final String KEY_ROOT = "root_profile"; - private static final Gson GSON = new Gson(); - private static final Type LIST_TYPE = - new TypeToken>() { }.getType(); - - /** The id of the built-in default profile. */ - public static final String DEFAULT_ID = "default"; - - private final SharedPreferences prefs; - - public WheelProfileStore(Context context) { - this.prefs = context.getApplicationContext() - .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - } - - /** - * The built-in default: the four destinations the wheel has always had, plus a nested PvP - * sub-wheel. This is what a fresh install shows, so the wheel is useful before it is edited. - */ - public static List defaultProfiles() { - List profiles = new ArrayList<>(); - WheelEntry.Profile root = new WheelEntry.Profile(DEFAULT_ID, "Main"); - root.entries.add(new WheelEntry(WheelEntry.Action.OPEN_MODS, "", "Mods")); - root.entries.add(new WheelEntry(WheelEntry.Action.OPEN_CLOSET, "", "Closet")); - root.entries.add(new WheelEntry(WheelEntry.Action.OPEN_HUD, "", "HUD")); - root.entries.add(new WheelEntry(WheelEntry.Action.OPEN_SUBWHEEL, "pvp", "PvP")); - profiles.add(root); - - WheelEntry.Profile pvp = new WheelEntry.Profile("pvp", "PvP"); - pvp.entries.add(new WheelEntry(WheelEntry.Action.TOGGLE_MODULE, "cps_display", "CPS")); - pvp.entries.add(new WheelEntry(WheelEntry.Action.TOGGLE_MODULE, "hit_registration", "HitReg")); - pvp.entries.add(new WheelEntry(WheelEntry.Action.TOGGLE_MODULE, "snaplook", "Snaplook")); - pvp.entries.add(new WheelEntry(WheelEntry.Action.OPEN_SUBWHEEL, DEFAULT_ID, "Back")); - profiles.add(pvp); - return profiles; - } - - /** The stored profiles, or the built-in default when none are stored or the blob is unreadable. */ - public List load() { - String json = prefs.getString(KEY_PROFILES, null); - if (json == null || json.isEmpty()) return defaultProfiles(); - try { - List parsed = GSON.fromJson(json, LIST_TYPE); - if (parsed == null || parsed.isEmpty()) return defaultProfiles(); - return sanitize(parsed); - } catch (RuntimeException e) { - return defaultProfiles(); - } - } - - /** Persists the profiles, dropping anything that cannot be shown. */ - public void save(List profiles) { - List safe = sanitize(profiles); - if (safe.isEmpty()) safe = defaultProfiles(); - prefs.edit().putString(KEY_PROFILES, GSON.toJson(safe)).apply(); - } - - /** The id of the profile the bind opens. Falls back to the first profile. */ - public String getRootId() { - List profiles = load(); - String stored = prefs.getString(KEY_ROOT, null); - if (stored != null && findById(profiles, stored) != null) return stored; - return profiles.isEmpty() ? DEFAULT_ID : profiles.get(0).id; - } - - /** Sets the profile the bind opens, ignoring an id that does not exist. */ - public void setRootId(String id) { - if (id == null || findById(load(), id) == null) return; - prefs.edit().putString(KEY_ROOT, id).apply(); - } - - /** The profile with an id, or null. */ - public static WheelEntry.Profile findById(List profiles, String id) { - if (profiles == null || id == null) return null; - for (WheelEntry.Profile p : profiles) { - if (p != null && id.equals(p.id)) return p; - } - return null; - } - - /** A new id that no profile in the list uses, so a created profile is never ambiguous. */ - public static String uniqueId(List profiles, String base) { - String stem = (base == null || base.trim().isEmpty()) ? "wheel" : base.trim(); - if (findById(profiles, stem) == null) return stem; - for (int i = 2; i < 1000; i++) { - String candidate = stem + i; - if (findById(profiles, candidate) == null) return candidate; - } - return stem + System.nanoTime(); - } - - /** - * Adds a new empty profile and returns it. - * - *

The manager's "create" action. The id is derived from the name so a player who names a - * wheel "PvP" gets {@code pvp}, and it is de-duplicated so two wheels may share a name. - */ - public WheelEntry.Profile create(String name) { - List profiles = load(); - String id = uniqueId(profiles, slug(name)); - WheelEntry.Profile profile = new WheelEntry.Profile(id, (name == null || name.trim().isEmpty()) - ? id : name.trim()); - profiles.add(profile); - save(profiles); - return profile; - } - - /** Renames a profile, leaving its id (and any sub-wheel reference to it) untouched. */ - public boolean rename(String id, String name) { - List profiles = load(); - WheelEntry.Profile profile = findById(profiles, id); - if (profile == null) return false; - if (name != null && !name.trim().isEmpty()) profile.name = name.trim(); - save(profiles); - return true; - } - - /** - * Deletes a profile and repoints any sub-wheel that referred to it. - * - *

A dangling {@code OPEN_SUBWHEEL} entry would draw a sector that does nothing, so every - * reference to the deleted id is rewritten to the fallback (the first remaining profile). The - * last profile is never deleted — the wheel would have nothing to open. - */ - public boolean delete(String id, String fallbackId) { - List profiles = load(); - if (profiles.size() <= 1) return false; - WheelEntry.Profile removed = findById(profiles, id); - if (removed == null) return false; - profiles.remove(removed); - String fallback = fallbackId != null && findById(profiles, fallbackId) != null - ? fallbackId : profiles.get(0).id; - repointSubWheels(profiles, id, fallback); - save(profiles); - if (id.equals(getRootId())) setRootId(fallback); - return true; - } - - /** - * Rewrites every sub-wheel reference to a deleted profile id, in place. - * - *

Pure and static so the repointing rule is unit-testable without a Context: a dangling - * {@code OPEN_SUBWHEEL} entry would draw a sector that does nothing when tapped. - */ - public static void repointSubWheels(List profiles, String removedId, - String fallbackId) { - if (profiles == null || removedId == null || fallbackId == null) return; - for (WheelEntry.Profile p : profiles) { - if (p == null) continue; - for (WheelEntry e : p.safeEntries()) { - if (e != null && e.isSubWheel() && removedId.equals(e.target)) e.target = fallbackId; - } - } - } - - /** - * Moves a profile one position towards the start or the end of the list. - * - * @param direction negative to move up, positive to move down - * @return true when the list changed - */ - public boolean reorder(String id, int direction) { - if (direction == 0) return false; - List profiles = load(); - int from = -1; - for (int i = 0; i < profiles.size(); i++) { - if (profiles.get(i) != null && profiles.get(i).id.equals(id)) { - from = i; - break; - } - } - if (from < 0) return false; - int to = from + (direction < 0 ? -1 : 1); - if (to < 0 || to >= profiles.size()) return false; - WheelEntry.Profile moved = profiles.remove(from); - profiles.add(to, moved); - save(profiles); - return true; - } - - // --- Entry editing --------------------------------------------------------------------- - // - // The spec's manager edits "profiles and entries", and the entries are what a player actually - // changes: which action sits in which sector. These methods own that, so the manager never - // rewrites a profile by hand and a malformed edit cannot reach the store. - - /** - * Replaces one profile's entry list. - * - *

The list is sanitised the same way {@link #save} sanitises a whole profile set, so a bad - * label or a ninth entry cannot be written. A sub-wheel entry pointing at the profile's own id - * is refused (it would recurse forever), and one pointing at an id that does not exist is - * dropped, because a sector that opens nothing reads as broken. - * - * @return true when the profile existed and the write happened - */ - public boolean setEntries(String profileId, List entries) { - List profiles = load(); - WheelEntry.Profile profile = findById(profiles, profileId); - if (profile == null) return false; - profile.entries = sanitizeEntries(entries, profileId, profiles); - save(profiles); - return true; - } - - /** - * The pure half of {@link #setEntries}: the rule for which entries a profile may hold. - * - *

Split out so it is unit-testable without a Context (the store's persistence needs one, the - * rule does not). A sub-wheel entry pointing at the profile's own id is refused because it would - * recurse forever, and one pointing at an id that does not exist is dropped because a sector - * that opens nothing reads as broken. - */ - public static List sanitizeEntries(List entries, String profileId, - List profiles) { - List safe = new ArrayList<>(); - if (entries == null) return safe; - for (WheelEntry e : entries) { - if (e == null) continue; - if (safe.size() >= WheelLayout.MAX_SECTORS) break; - e.label = WheelEntry.normalizeLabel(e.label); - if (e.isSubWheel() && (e.target == null || e.target.trim().isEmpty() - || e.target.equals(profileId) || findById(profiles, e.target) == null)) { - continue; - } - safe.add(e); - } - return safe; - } - - /** - * Appends an entry to a profile, refusing a full wheel. - * - * @return true when the entry was added - */ - public boolean addEntry(String profileId, WheelEntry entry) { - List profiles = load(); - WheelEntry.Profile profile = findById(profiles, profileId); - if (profile == null || entry == null) return false; - List current = new ArrayList<>(profile.safeEntries()); - if (current.size() >= WheelLayout.MAX_SECTORS) return false; - current.add(entry); - return setEntries(profileId, current); - } - - /** Removes an entry by index, refusing an out-of-range index. */ - public boolean removeEntry(String profileId, int index) { - List profiles = load(); - WheelEntry.Profile profile = findById(profiles, profileId); - if (profile == null) return false; - List current = new ArrayList<>(profile.safeEntries()); - if (index < 0 || index >= current.size()) return false; - current.remove(index); - return setEntries(profileId, current); - } - - /** - * Moves an entry one position, which is what changes the sector an action sits in. - * - * @param direction negative to move towards the top sector, positive towards the last - * @return true when the order changed - */ - public boolean reorderEntry(String profileId, int index, int direction) { - if (direction == 0) return false; - List profiles = load(); - WheelEntry.Profile profile = findById(profiles, profileId); - if (profile == null) return false; - List current = new ArrayList<>(profile.safeEntries()); - if (index < 0 || index >= current.size()) return false; - int to = index + (direction < 0 ? -1 : 1); - if (to < 0 || to >= current.size()) return false; - current.add(to, current.remove(index)); - return setEntries(profileId, current); - } - - /** A profile id derived from a display name: lower-case, non-alphanumerics dropped. */ - public static String slug(String name) { - if (name == null) return "wheel"; - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < name.length(); i++) { - char c = Character.toLowerCase(name.charAt(i)); - if (Character.isLetterOrDigit(c)) sb.append(c); - } - return sb.length() == 0 ? "wheel" : sb.toString(); - } - - /** - * The root profile, or the first profile, or a fresh default. - * - *

Never null: a wheel with no profile cannot be drawn, and the caller would have to invent - * one at the draw site. - */ - public WheelEntry.Profile rootProfile() { - List profiles = load(); - WheelEntry.Profile root = findById(profiles, getRootId()); - if (root != null) return root; - if (!profiles.isEmpty()) return profiles.get(0); - return defaultProfiles().get(0); - } - - /** - * Drops entries a profile cannot draw and gives every profile a usable id and label. - * - *

A profile with no id cannot be referenced by a sub-wheel entry, and a duplicate id makes - * the reference ambiguous, so both are repaired here rather than at the lookup. - */ - public static List sanitize(List profiles) { - List out = new ArrayList<>(); - if (profiles == null) return out; - List seenIds = new ArrayList<>(); - for (WheelEntry.Profile p : profiles) { - if (p == null) continue; - if (p.id == null || p.id.trim().isEmpty()) continue; - if (seenIds.contains(p.id)) continue; - seenIds.add(p.id); - if (p.name == null || p.name.trim().isEmpty()) p.name = p.id; - List safe = new ArrayList<>(); - for (WheelEntry e : p.safeEntries()) { - if (e == null) continue; - e.label = WheelEntry.normalizeLabel(e.label); - safe.add(e); - } - p.entries = safe; - out.add(p); - } - return out; - } -} diff --git a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/vip/ModCategory.java b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/vip/ModCategory.java index 70f2e9a7..b320986e 100644 --- a/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/vip/ModCategory.java +++ b/app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/vip/ModCategory.java @@ -32,7 +32,7 @@ public enum Tab { ModIds.FULLBRIGHT, ModIds.TIME_CHANGER, ModIds.LOW_FIRE, ModIds.BLOCK_OUTLINE, ModIds.MOTION_BLUR, ModIds.SATURATION, ModIds.CROSSHAIR, ModIds.ARMOR_VIEW_BENDING, ModIds.PROJECTILE_SCALER, ModIds.FREECAM, ModIds.TNT, ModIds.ZOOM, ModIds.FPS_DISPLAY, - ModIds.CPS_DISPLAY); + ModIds.CPS_DISPLAY, ModIds.FRAME_GENERATION); /** The information/utility modules the MISC tab collects. */ private static final Set MISC = Api28Compat.setOf( diff --git a/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderInput.java b/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderInput.java index 959d798a..43683ca8 100644 --- a/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderInput.java +++ b/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderInput.java @@ -102,6 +102,33 @@ public static int closeUnusedModuleHandles() { */ public static native boolean nativeIsPlayerRenderHookLive(); + /** + * Whether the engine's present / frame-boundary hook is installed (the {@code eglSwapBuffers} + * detour). Frame generation and motion blur both act on the presented frame, so this is the probe + * that decides whether they can run on this build: false on a Vulkan-only build with no libEGL + * present call, in which case the modules stay badged instead of toggling to no effect. + */ + public static native boolean nativeIsPresentHookLive(); + + /** + * Pushes the present-frame effect plan (frame generation + motion blur) to the native seam. + * + *

Values are clamped on the native side, so a stored option can never drive an out-of-range + * blend. A no-op when the seam is not live: the launcher keeps both modules badged and the plan + * is simply never acted on. + */ + public static native void nativeSetPresentFramePlan( + boolean frameGenerationEnabled, float interpolationStrength, int maxFramesPerRendered, + boolean reduceEdgeArtifacts, boolean motionBlurEnabled, float blurStrength, + float blurBudget, boolean weightByViewSpeed); + + /** + * Whether the present-frame effect seam is installed and live. False until the engine's present + * boundary has been seen with a current EGL context and the composite program has compiled, so a + * module never reads as running before it can actually draw. + */ + public static native boolean nativeIsPresentFrameEffectLive(); + /** * The render tick and per-frame call count, as * {@code {renderTick, callsThisFrame, totalCalls, msSinceLastRender}}, or null when the @@ -585,10 +612,58 @@ public static boolean isPlayerRenderHookLive() { } } + /** + * True once the engine's present / frame-boundary hook is installed. + * + *

Fail-closed: an unavailable native library, or a build with no libEGL present call, reads as + * false, so frame generation and motion blur stay badged rather than acting on a frame boundary + * that was never observed. + */ + public static boolean isPresentHookLive() { + try { + return nativeIsPresentHookLive(); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + /** + * Pushes the present-frame effect plan to the native seam. + * + *

Fail-closed: an unavailable native library is a no-op, so the plan simply never reaches a + * seam and both modules stay badged. + */ + public static void setPresentFramePlan(boolean frameGenerationEnabled, float interpolationStrength, + int maxFramesPerRendered, boolean reduceEdgeArtifacts, + boolean motionBlurEnabled, float blurStrength, + float blurBudget, boolean weightByViewSpeed) { + try { + nativeSetPresentFramePlan(frameGenerationEnabled, interpolationStrength, + maxFramesPerRendered, reduceEdgeArtifacts, motionBlurEnabled, blurStrength, + blurBudget, weightByViewSpeed); + } catch (UnsatisfiedLinkError e) { + AppLog.warnOnce("CaramelClient", "PreloaderInput:present-frame-plan", + "No native present-frame seam; the plan is not applied", e); + } + } + + /** True once the present-frame effect seam is installed and live. Fail-closed. */ + public static boolean isPresentFrameEffectLive() { + try { + return nativeIsPresentFrameEffectLive(); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + /** * The render tick and call counts, or null when the renderer has not run. * - * @return {@code {renderTick, callsThisFrame, totalCalls, msSinceLastRender}}, or null. + * @return {@code {renderTick, callsThisFrame, totalCalls, msSinceLastRender, tickExact}}, or + * null. {@code tickExact} is 1 when the tick is the engine's exact present boundary (the + * {@code eglSwapBuffers} hook is live) and 0 when it is inferred from render-entry timestamps. + * A reader that derives a frame rate must label an inferred tick as approximate. Older + * libraries returned four elements; a shorter array is accepted and treated as inexact. */ public static int[] readPlayerRenderStats() { try { @@ -599,6 +674,17 @@ public static int[] readPlayerRenderStats() { } } + /** + * Whether the render tick is the engine's exact present boundary rather than an inference. + * + *

Fail-closed: a missing library, a null reading or a short array all read as false, so a + * derived frame rate is labelled approximate rather than presented as the game's exact rate. + */ + public static boolean isRenderTickExact() { + int[] value = readPlayerRenderStats(); + return value != null && value.length >= 5 && value[4] == 1; + } + /** * Samples the native cape chain, or null when the native library is unavailable. * diff --git a/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java b/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java new file mode 100644 index 00000000..ab306668 --- /dev/null +++ b/app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java @@ -0,0 +1,142 @@ +package org.chimeramc.client.preloader; + +import org.chimeramc.client.util.AppLog; + +/** + * The in-game Mod Menu, drawn natively with Dear ImGui on the game's own GL context. + * + *

This is the client half of the menu the UI pack ships as {@code imgui/src/CaramelMenu.cpp} + * (Lynxifyy/CaramelClient-UI-pack). The pack's desktop GLFW backend has no Android path, so the + * menu is rendered from the sweetloader's present hook instead, where the game's GLES context is + * current. The layout, glass widget kit and tokens are the pack's; the module data is this client's + * real catalogue. + * + *

Fail-closed. Every entry point is a no-op when the sweetloader is absent or its present + * hook is not live, and {@link #isMenuLive()} then reports false, so the launcher can badge the menu + * rather than open a menu that cannot draw. + */ +public final class PreloaderMenu { + + private static final String TAG = "PreloaderMenu"; + + private PreloaderMenu() { + } + + /** Asks the sweetloader to prepare the menu. Safe to call repeatedly. */ + public static void install() { + try { + nativeInstallMenu(); + } catch (UnsatisfiedLinkError e) { + AppLog.warnOnce(TAG, "PreloaderMenu:install", "The sweetloader is not loaded", e); + } + } + + /** True once the menu's ImGui context and GLES backend are up in the game process. */ + public static boolean isMenuLive() { + try { + return nativeIsMenuLive(); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + /** Opens or closes the menu. */ + public static void setOpen(boolean open) { + try { + nativeSetMenuOpen(open); + } catch (UnsatisfiedLinkError e) { + // The menu cannot exist without the library; a failure to open or close it is worth a + // line in the log, not silence. + AppLog.warnOnce(TAG, "PreloaderMenu:setOpen", "The sweetloader is not loaded", e); + } + } + + public static boolean isOpen() { + try { + return nativeIsMenuOpen(); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + /** + * Forwards a touch event to the menu. + * + * @param action the {@code MotionEvent} action mask (0 = down, 1 = up, 2 = move, 3 = cancel). + * @param x the touch x in the view's coordinate space. + * @param y the touch y in the view's coordinate space. + * @param viewWidth the view width the coordinate is in, or 0 to skip scaling. + * @param viewHeight the view height the coordinate is in, or 0 to skip scaling. + */ + public static void pushTouch(int action, float x, float y, float viewWidth, float viewHeight) { + try { + nativePushTouch(action, x, y, viewWidth, viewHeight); + } catch (UnsatisfiedLinkError e) { + AppLog.warnOnce(TAG, "PreloaderMenu:pushTouch", "The sweetloader is not loaded", e); + } + } + + /** Reports a back/close request (the back button or a controller's B). */ + public static void pushBack() { + try { + nativePushBack(); + } catch (UnsatisfiedLinkError e) { + AppLog.warnOnce(TAG, "PreloaderMenu:pushBack", "The sweetloader is not loaded", e); + } + } + + /** + * Replaces the module catalogue the Mods tab shows. + * + * @param json a {@code {"modules":[...],"empty_reason":"..."}} document, built by + * {@code InGameMenuCatalog}. + * @return true when the catalogue was accepted; false when it was rejected as malformed. + */ + public static boolean setCatalogue(String json) { + try { + return nativeSetCatalogue(json); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + /** + * Drains the interactions the menu raised since the last call, as a JSON array of + * {@code {"kind":...,"key":...,"value":...}} objects. + */ + public static String drainEvents() { + try { + return nativeDrainEvents(); + } catch (UnsatisfiedLinkError e) { + return "[]"; + } + } + + /** Registers a font face for a menu role (see {@code InGameMenuCatalog.ROLE_*}). */ + public static boolean registerFont(int role, byte[] data) { + try { + return nativeRegisterFont(role, data); + } catch (UnsatisfiedLinkError e) { + return false; + } + } + + private static native void nativeInstallMenu(); + + private static native boolean nativeIsMenuLive(); + + private static native void nativeSetMenuOpen(boolean open); + + private static native boolean nativeIsMenuOpen(); + + private static native void nativePushTouch(int action, float x, float y, float viewWidth, + float viewHeight); + + private static native void nativePushBack(); + + private static native boolean nativeSetCatalogue(String json); + + private static native String nativeDrainEvents(); + + private static native boolean nativeRegisterFont(int role, byte[] data); +} diff --git a/app/src/main/java/org/chimeramc/client/launcher/ui/activities/MainActivity.java b/app/src/main/java/org/chimeramc/client/launcher/ui/activities/MainActivity.java index 68580030..a874f128 100644 --- a/app/src/main/java/org/chimeramc/client/launcher/ui/activities/MainActivity.java +++ b/app/src/main/java/org/chimeramc/client/launcher/ui/activities/MainActivity.java @@ -701,7 +701,7 @@ private void initializeAfterMigrationGate() { } private void initializeVersionManager() { - binding.launchDock.launchButton.setEnabled(false); + setLaunchControlEnabled(false); versionManager = VersionManager.getIfInitialized(); if (versionManager != null) { onVersionManagerReady(); @@ -1328,10 +1328,11 @@ private void applyCardRounding(int roundingDp) { float density = getResources().getDisplayMetrics().density; float radiusPx = roundingDp * density; - // Apply to launch button - if (binding.launchDock.launchButton.getBackground() instanceof GradientDrawable) { - ((GradientDrawable) binding.launchDock.launchButton.getBackground()).setCornerRadius(radiusPx); - } + // The launch face is a state-list drawable, so there is no single GradientDrawable to + // retune; its 20dp radius comes from @dimen/radius_split_launch and is deliberately fixed, + // because the corner-radius preference is a card/shape preference and the pack's launch + // control is its own shape. Rounding it here would also break the flush seam with the + // chevron segment, which shares that radius on its trailing edge only. // Apply to select version button if (binding.launchDock.selectVersionButton.getBackground() instanceof GradientDrawable) { @@ -1528,9 +1529,13 @@ public void onRequestPermissionsResult(int requestCode, String[] permissions, in @SuppressLint({"ClickableViewAccessibility", "UnsafeIntentLaunch"}) private void initListeners() { - binding.launchDock.launchButton.setEnabled(false); + setLaunchControlEnabled(false); binding.launchDock.launchButton.setOnClickListener(v -> launchGame()); DynamicAnim.applyPressScale(binding.launchDock.launchButton); + // The split button's chevron is the other half: it opens the instance/version picker, so + // tapping the wrong half never starts a session. + binding.launchDock.launchChevron.setOnClickListener(v -> showVersionSelectDialog()); + DynamicAnim.applyPressScale(binding.launchDock.launchChevron); binding.launchDock.selectVersionButton.setOnClickListener(v -> showVersionSelectDialog()); DynamicAnim.applyPressScale(binding.launchDock.selectVersionButton); @@ -1726,7 +1731,7 @@ private void refreshToggleRow() { */ private void setupDockFeedback() { launchShimmer = findViewById(R.id.launch_button_shimmer); - Button play = binding != null ? binding.launchDock.launchButton : null; + View play = binding != null ? binding.launchDock.launchButton : null; if (play == null) return; // The button starts disabled until the version manager is ready; start the shimmer now and // let resetLaunchButton() stop it, so the control never reads as frozen on a cold start. @@ -2107,7 +2112,7 @@ private void initLaunchQuickDock() { } View wheel = findViewById(R.id.quickdock_wheel); if (wheel != null) { - wheel.setOnClickListener(v -> openRadialWheelFromHome()); + wheel.setOnClickListener(v -> openModsFromHome()); DynamicAnim.applyPressScale(wheel); DynamicAnim.applyHoverBloom(wheel); } @@ -2135,16 +2140,15 @@ private void openProfileSheet() { } /** - * Shows the radial wheel over the launcher, so the dock's wheel entry is the same menu the - * in-game bind opens. The wheel needs no game session to draw. + * Opens the launcher's Mods screen. + * + *

This dock entry used to raise the radial wheel over the launcher. The wheel was removed + * with the Java menu screen, and the launcher cannot host the native menu (that draws on the + * game's GL context and needs a running session), so the entry opens the launcher's own Mods + * screen — the same module list, as a normal screen. */ - private void openRadialWheelFromHome() { - try { - org.chimeramc.client.core.mods.inbuilt.overlay.RadialMenuOverlay - .showForActivity(this); - } catch (Throwable t) { - Toast.makeText(this, R.string.launch_quickdock_wheel_unavailable, Toast.LENGTH_SHORT).show(); - } + private void openModsFromHome() { + openModsFullscreen(); } private void openModsFullscreen() { @@ -2157,10 +2161,20 @@ private void launchGame() { performActualLaunch(); } + /** Enables or disables both halves of the split launch control as one. */ + private void setLaunchControlEnabled(boolean enabled) { + if (binding == null) return; + binding.launchDock.launchButton.setEnabled(enabled); + // The chevron opens a picker, which is meaningless while the version manager is not ready, + // so it is disabled with the face: a live chevron on a dead control is a confusing tap. + binding.launchDock.launchChevron.setEnabled(enabled); + binding.launchDock.launchChevron.setAlpha(enabled ? 1f : 0.55f); + } + /** Clears the in-flight flag and re-enables the Play button after a launch is abandoned. */ private void resetLaunchButton() { launchInProgress = false; - if (binding != null) binding.launchDock.launchButton.setEnabled(true); + setLaunchControlEnabled(true); stopLaunchShimmer(); } @@ -2173,7 +2187,7 @@ private void performActualLaunch() { // binding is nullable on this screen (resetLaunchButton guards it), so dereferencing it // unguarded here threw on the UI thread when the launch was triggered from a path where the // view had not been bound yet. - if (binding != null) binding.launchDock.launchButton.setEnabled(false); + setLaunchControlEnabled(false); LaunchTrace trace = LaunchTrace.create(null); trace.milestone("Launch requested"); @@ -2644,12 +2658,17 @@ public void setTextMinecraftVersion() { if (binding == null) return; if (versionManager == null) { binding.launchDock.textMinecraftVersion.setText(getString(R.string.not_found_version)); + binding.launchDock.launchButtonSubtitle.setText(getString(R.string.not_found_version)); updateLastPlayedName(null, null); return; } GameVersion selectedVersion = versionManager.getSelectedVersion(); String instanceName = selectedVersion != null ? getInstanceDisplayName(selectedVersion) : null; binding.launchDock.textMinecraftVersion.setText(TextUtils.isEmpty(instanceName) ? getString(R.string.not_found_version) : instanceName); + // The split button names the build it will start, so the action is unambiguous without + // reading the picker above it. + binding.launchDock.launchButtonSubtitle.setText(TextUtils.isEmpty(instanceName) + ? getString(R.string.not_found_version) : instanceName); updateLastPlayedName(instanceName, selectedVersion); refreshLastPlayedCard(); } diff --git a/app/src/main/java/org/chimeramc/client/launcher/ui/dialogs/ProfileSheet.java b/app/src/main/java/org/chimeramc/client/launcher/ui/dialogs/ProfileSheet.java index fadf370b..6e3595b3 100644 --- a/app/src/main/java/org/chimeramc/client/launcher/ui/dialogs/ProfileSheet.java +++ b/app/src/main/java/org/chimeramc/client/launcher/ui/dialogs/ProfileSheet.java @@ -37,10 +37,9 @@ * together with the account row and the skin-pack manager, which are the other things that describe * the player. * - *

Two inner tabs, exactly as the old Customize host had: the Closet (the same - * {@link CosmeticsPanel} the in-game Mod Menu hosts, so there is no second catalogue) and Skins - * (the same {@link SkinsSettingsFragment} the Customize host used). The header is the one place a - * player can see who they are signed in as and switch accounts. + *

Two inner tabs: the Closet (the same {@link CosmeticsPanel} the rest of the launcher uses, so + * there is no second catalogue) and Skins (the same {@link SkinsSettingsFragment} the Customize host + * used). The header is the one place a player can see who they are signed in as and switch accounts. */ public class ProfileSheet extends Dialog { diff --git a/app/src/main/java/org/chimeramc/client/ui/components/PointerCursorRenderer.java b/app/src/main/java/org/chimeramc/client/ui/components/PointerCursorRenderer.java index 60aae037..04041326 100644 --- a/app/src/main/java/org/chimeramc/client/ui/components/PointerCursorRenderer.java +++ b/app/src/main/java/org/chimeramc/client/ui/components/PointerCursorRenderer.java @@ -6,11 +6,11 @@ * Draws the macOS-style pointer without allocating on the draw path (Phase 12). * *

{@link UiDraw#pointerCursor} builds a fresh {@code float[]} and {@code Path} on every call, and - * the three cursor hosts ({@code MenuCursorOverlay}, {@code RadialMenuView}, - * {@code LauncherCursorOverlay}) draw the pointer inside {@code onDraw}. That is two allocations per - * frame on the input-to-photon path for a shape whose outline never changes — only its position - * does. This class owns one {@link Path} and one scratch {@code float[]}, rebuilds the path only - * when the cursor actually moves, and otherwise reuses it, so a held-still cursor allocates nothing. + * the cursor hosts ({@code MenuCursorOverlay}, {@code LauncherCursorOverlay}) draw the pointer inside + * {@code onDraw}. That is two allocations per frame on the input-to-photon path for a shape whose + * outline never changes — only its position does. This class owns one {@link Path} and one scratch + * {@code float[]}, rebuilds the path only when the cursor actually moves, and otherwise reuses it, so + * a held-still cursor allocates nothing. * *

The geometry itself stays in {@link PointerCursorGeometry}; this class only caches the * transform, so the shape is still unit-testable through the pure class. diff --git a/app/src/main/java/org/chimeramc/client/ui/components/UiTokens.java b/app/src/main/java/org/chimeramc/client/ui/components/UiTokens.java index 4a8a11c3..6fbb3bbb 100644 --- a/app/src/main/java/org/chimeramc/client/ui/components/UiTokens.java +++ b/app/src/main/java/org/chimeramc/client/ui/components/UiTokens.java @@ -13,10 +13,17 @@ * ({@code ModMenuTheme} and {@code VipTheme}). Every screen — the radial wheel, the one mod menu, the * Closet and the Launch tab — reads from here, so a screen cannot drift onto its own colours. * - *

The values are the "blueberry" palette: a near-black, very slightly cool window on a blue - * accent. The user's selectable accent still applies (the mod menus and the wheel draw themselves, - * so they resolve it here), but blue is the default — {@link #resolveAccent} falls back to - * {@link #ACCENT_500} whenever no custom accent has been chosen. + *

The values are the {@code CaramelClient-UI-pack}'s navy-and-glass palette, adopted verbatim: + * the pack is the design reference, so its numbers are the ones the client draws. That means a + * deep-navy window ramp ({@code Abyss #060B1A} → {@code Navy #0A1330} → {@code DeepBlue #0F1B44}), + * the pack's brighter blue accent ({@code #4A78FF}), its caramel ({@code #F5A742}) and its three-step + * text ramp. The old "blueberry" ramp ({@code #17191D} on {@code #0F5BE4}) was a grey-on-blue + * palette; the pack's is navy-on-blue, which is what makes a surface read as glass rather than as a + * dark rectangle. + * + *

The user's selectable accent still applies (the mod menus and the wheel draw themselves, so + * they resolve it here), but the pack's blue is the default — {@link #resolveAccent} falls + * back to {@link #ACCENT_500} whenever no custom accent has been chosen. * *

Android-free apart from {@link Context} for the accent preference; the constants and the pure * derivations are unit-tested without a device. @@ -28,41 +35,50 @@ private UiTokens() { // --- Colours: window and surfaces -------------------------------------------------------- - /** The window panel fill (alpha 0.96). */ - public static final int BG_WINDOW = 0xF5111214; - public static final int BG_SURFACE = 0xFF17191D; - public static final int BG_SURFACE_HOVER = 0xFF1E2126; - public static final int BG_TITLEBAR = 0xFF141418; + /** The window panel fill (alpha 0.96) over the pack's {@code Abyss}. */ + public static final int BG_WINDOW = 0xF5060B1A; + /** A panel surface, the pack's {@code Navy}. */ + public static final int BG_SURFACE = 0xFF0A1330; + /** A raised surface, the pack's {@code DeepBlue}. */ + public static final int BG_SURFACE_HOVER = 0xFF0F1B44; + /** The titlebar: one step under {@link #BG_SURFACE}, so it reads as a recessed rail. */ + public static final int BG_TITLEBAR = 0xFF081026; /** 1dp borders and dividers. */ public static final int STROKE_HAIRLINE = 0x12FFFFFF; // rgba(255,255,255,0.07) // --- Colours: accent --------------------------------------------------------------------- - /** Toggle ON, selected rail icon, primary button. */ - public static final int ACCENT_500 = 0xFF0F5BE4; - /** Hover/pressed lighter, gradient end. */ - public static final int ACCENT_400 = 0xFF3682E2; - /** Icons on dark, focus ring. */ - public static final int ACCENT_300 = 0xFF71ACEF; - /** Tinted selected-row background. */ - public static final int ACCENT_900 = 0xFF10254F; + /** Toggle ON, selected rail icon, primary button. The pack's {@code Blue}. */ + public static final int ACCENT_500 = 0xFF4A78FF; + /** Hover/pressed lighter, gradient end. The pack's {@code BlueLight}. */ + public static final int ACCENT_400 = 0xFF628CFF; + /** Icons on dark, focus ring. A step lighter still, for a ring that must be unmistakable. */ + public static final int ACCENT_300 = 0xFF7FA0FF; + /** Tinted selected-row background. The pack's {@code DeepBlue} family, darkened for a wash. */ + public static final int ACCENT_900 = 0xFF152252; /** Section tint. */ - public static final int NAVY_TINT = 0xFF1D283D; + public static final int NAVY_TINT = 0xFF16224A; // --- Colours: text ----------------------------------------------------------------------- - public static final int TEXT_PRIMARY = 0xFFE8EAED; - public static final int TEXT_SECONDARY = 0xFF9AA0A6; - public static final int TEXT_DISABLED = 0xFF5F6368; + /** The pack's {@code Text}. */ + public static final int TEXT_PRIMARY = 0xFFEAF0FF; + /** The pack's {@code Text2}. */ + public static final int TEXT_SECONDARY = 0xFF9AA8D0; + /** The pack's {@code Text3}. */ + public static final int TEXT_DISABLED = 0xFF66749E; - public static final int TOGGLE_OFF_TRACK = 0xFF2A2D33; + public static final int TOGGLE_OFF_TRACK = 0xFF232C40; // --- Colours: status (status only) ------------------------------------------------------- - public static final int SUCCESS = 0xFF3DDC84; - public static final int WARNING = 0xFFFFB020; - public static final int DANGER = 0xFFFF5C5C; + /** The pack's {@code Green}. */ + public static final int SUCCESS = 0xFF2FD36B; + /** The pack's {@code Orange}. */ + public static final int WARNING = 0xFFFF9F2E; + /** The pack's {@code Red}. */ + public static final int DANGER = 0xFFFF5468; // --- Radial wheel (translucent, over the game) ------------------------------------------- @@ -310,4 +326,19 @@ public static double contrastRatio(int a, int b) { double darker = Math.min(la, lb); return (lighter + 0.05) / (darker + 0.05); } + + /** + * Whether a surface colour is navy (blue-dominant) rather than a neutral grey. + * + *

The pack's whole look rests on this: a surface whose blue channel is not the strongest is a + * grey rectangle, which is the "unfinished" look the navy ramp exists to replace. The check is + * deliberately strict — blue must be at least as strong as both other channels — so a colour that + * merely leans slightly blue is not mistaken for navy. + */ + public static boolean isBlueDominant(int color) { + int r = (color >>> 16) & 0xFF; + int g = (color >>> 8) & 0xFF; + int b = color & 0xFF; + return b >= r && b >= g; + } } diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenu.kt b/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenu.kt deleted file mode 100644 index db44685c..00000000 --- a/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenu.kt +++ /dev/null @@ -1,607 +0,0 @@ -package org.chimeramc.client.ui.compose.modmenu - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.outlined.CheckBox -import androidx.compose.material.icons.outlined.Inventory2 -import androidx.compose.material.icons.outlined.Person -import androidx.compose.material.icons.outlined.Save -import androidx.compose.material.icons.outlined.StarBorder -import androidx.compose.material.icons.outlined.Tune -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import org.chimeramc.client.R -import org.chimeramc.client.ui.compose.theme.GlowDimens -import org.chimeramc.client.ui.compose.theme.glowColors -import org.chimeramc.client.ui.compose.theme.glowMotion - -/** - * The rebuilt Mod Menu: a glassmorphic sidebar with four destinations and a content pane. - * - *

Every surface is translucent glass rather than a flat fill, so the game behind the overlay - * stays legible; the rim catches light from above and the sheen falls off downward, which is what - * makes a translucent panel read as glass instead of as a washed-out rectangle. The sidebar icons - * are Material's detailed outlined set at a legible size, not the small glyphs the old rail used. - * - *

Pure presentation: the state arrives as one immutable [ModMenuState] and every interaction is - * a callback, so the menu can be rendered and reviewed without a device or a running game. - */ -@Composable -fun ModMenu( - state: ModMenuState, - onDestination: (ModMenuDestination) -> Unit, - onSearch: (String) -> Unit, - onToggleModule: (String, Boolean) -> Unit, - onToggleFavorite: (String) -> Unit, - onToggleFavoritesOnly: () -> Unit, - onToggleSetting: (String, Boolean) -> Unit, - onClose: () -> Unit, - modifier: Modifier = Modifier, -) { - val colors = glowColors - // The whole panel is one translucent glass sheet. 25% by default, the value the design calls - // for: enough to read the game through it, enough tint to keep the text legible. - val alpha = (state.opacityPercent.coerceIn(5, 100) / 100f) - val panelBase = (if (state.glassTint != 0) Color(state.glassTint) else colors.surface) - .copy(alpha = alpha) - - Box( - modifier = modifier - .fillMaxSize() - .padding(GlowDimens.SPACE_4.dp), - ) { - Row( - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(GlowDimens.CARD_RADIUS.dp)) - .background(panelBase) - .background( - Brush.verticalGradient( - 0f to Color.White.copy(alpha = 0.06f), - 0.45f to Color.Transparent, - ), - ) - .border( - width = 1.dp, - color = Color.White.copy(alpha = 0.16f), - shape = RoundedCornerShape(GlowDimens.CARD_RADIUS.dp), - ), - ) { - ModMenuSidebar( - destination = state.destination, - onDestination = onDestination, - onClose = onClose, - ) - Box( - modifier = Modifier - .fillMaxHeight() - .width(1.dp) - .background(Color.White.copy(alpha = 0.10f)), - ) - Column(modifier = Modifier.fillMaxSize()) { - ModMenuTopBar( - title = if (state.title.isNotEmpty()) state.title - else stringResource(R.string.mod_menu_title), - state = state, - onSearch = onSearch, - onToggleFavoritesOnly = onToggleFavoritesOnly, - onClose = onClose, - ) - Box(modifier = Modifier.fillMaxSize()) { - when (state.destination) { - ModMenuDestination.MODULES -> ModMenuModuleList( - state = state, - onToggleModule = onToggleModule, - onToggleFavorite = onToggleFavorite, - ) - ModMenuDestination.COSMETICS -> ModMenuPlaceholder( - icon = Icons.Outlined.Person, - text = stringResource(R.string.mod_menu_tab_cosmetics), - ) - ModMenuDestination.SAVE -> ModMenuSaveList(state) - ModMenuDestination.SETTINGS -> ModMenuSettingsList( - state = state, - onToggleSetting = onToggleSetting, - ) - } - } - } - } - } -} - -/** - * The sidebar: the four destinations, and Settings pinned to the bottom. - * - * Settings sits apart from the other three because it configures the menu itself rather than the - * game; putting it in the same visual group as Modules invites the mis-click the old rail had. - */ -@Composable -private fun ModMenuSidebar( - destination: ModMenuDestination, - onDestination: (ModMenuDestination) -> Unit, - onClose: () -> Unit, -) { - val colors = glowColors - Column( - modifier = Modifier - .fillMaxHeight() - .width(196.dp) - .padding(vertical = GlowDimens.SPACE_4.dp), - verticalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_2.dp), - ) { - ModMenuSidebarItem( - icon = Icons.Outlined.Inventory2, - label = stringResource(R.string.mod_menu_tab_modules), - selected = destination == ModMenuDestination.MODULES, - onClick = { onDestination(ModMenuDestination.MODULES) }, - ) - ModMenuSidebarItem( - icon = Icons.Outlined.Person, - label = stringResource(R.string.mod_menu_tab_cosmetics), - selected = destination == ModMenuDestination.COSMETICS, - onClick = { onDestination(ModMenuDestination.COSMETICS) }, - ) - ModMenuSidebarItem( - icon = Icons.Outlined.Save, - label = stringResource(R.string.mod_menu_tab_save), - selected = destination == ModMenuDestination.SAVE, - onClick = { onDestination(ModMenuDestination.SAVE) }, - ) - Spacer(modifier = Modifier.weight(1f)) - ModMenuSidebarItem( - icon = Icons.Filled.Settings, - label = stringResource(R.string.mod_menu_tab_settings), - selected = destination == ModMenuDestination.SETTINGS, - onClick = { onDestination(ModMenuDestination.SETTINGS) }, - ) - } -} - -@Composable -private fun ModMenuSidebarItem( - icon: ImageVector, - label: String, - selected: Boolean, - onClick: () -> Unit, -) { - val colors = glowColors - val accent = MaterialTheme.colorScheme.primary - val tint by animateFloatAsState( - targetValue = if (selected) 1f else 0f, - animationSpec = tween(glowMotion.fastMs), - label = "sidebarTint", - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = GlowDimens.SPACE_2.dp) - .clip(RoundedCornerShape(GlowDimens.RADIUS_MEDIUM.dp)) - .background(accent.copy(alpha = 0.16f * tint)) - .clickable(onClick = onClick) - .padding( - horizontal = GlowDimens.SPACE_3.dp, - vertical = GlowDimens.SPACE_3.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_3.dp), - ) { - Icon( - imageVector = icon, - contentDescription = label, - tint = if (selected) accent else colors.textSecondary, - modifier = Modifier.size(22.dp), - ) - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - color = if (selected) colors.textPrimary else colors.textSecondary, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -/** The top bar: the destination title, the module count, the search box and the close button. */ -@Composable -private fun ModMenuTopBar( - title: String, - state: ModMenuState, - onSearch: (String) -> Unit, - onToggleFavoritesOnly: () -> Unit, - onClose: () -> Unit, -) { - val colors = glowColors - val accent = MaterialTheme.colorScheme.primary - Column(modifier = Modifier.padding(GlowDimens.SPACE_4.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_3.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = colors.textPrimary, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - if (state.destination == ModMenuDestination.MODULES) { - Text( - text = stringResource(R.string.mod_menu_active_count, state.activeCount), - style = MaterialTheme.typography.labelSmall, - color = colors.textSecondary, - ) - } - IconButton(onClick = onClose) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.mod_menu_close), - tint = colors.textSecondary, - modifier = Modifier.size(22.dp), - ) - } - } - AnimatedVisibility(visible = state.destination == ModMenuDestination.MODULES) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = GlowDimens.SPACE_3.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_2.dp), - ) { - TextField( - value = state.search, - onValueChange = onSearch, - singleLine = true, - placeholder = { - Text( - text = stringResource(R.string.mod_menu_search_hint), - color = colors.textDisabled, - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = null, - tint = colors.textDisabled, - modifier = Modifier.size(18.dp), - ) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.White.copy(alpha = 0.06f), - unfocusedContainerColor = Color.White.copy(alpha = 0.04f), - focusedTextColor = colors.textPrimary, - unfocusedTextColor = colors.textPrimary, - cursorColor = accent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - shape = RoundedCornerShape(GlowDimens.RADIUS_MEDIUM.dp), - modifier = Modifier.weight(1f), - ) - IconButton(onClick = onToggleFavoritesOnly) { - Icon( - imageVector = if (state.favoritesOnly) Icons.Filled.Star - else Icons.Outlined.StarBorder, - contentDescription = stringResource(R.string.mod_menu_favorites), - tint = if (state.favoritesOnly) accent else colors.textSecondary, - modifier = Modifier.size(22.dp), - ) - } - } - } - } -} - -/** The Modules destination: one flat list of modules with a switch, a star and a badge. */ -@Composable -private fun ModMenuModuleList( - state: ModMenuState, - onToggleModule: (String, Boolean) -> Unit, - onToggleFavorite: (String) -> Unit, -) { - val colors = glowColors - val rows = state.visibleRows - if (rows.isEmpty()) { - ModMenuPlaceholder( - icon = Icons.Outlined.Inventory2, - text = stringResource(R.string.mod_menu_empty_modules), - ) - return - } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = GlowDimens.SPACE_4.dp), - verticalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_2.dp), - ) { - items(rows, key = { it.stableKey }) { row -> - ModMenuModuleRow( - row = row, - onToggle = { onToggleModule(row.stableKey, it) }, - onFavorite = { onToggleFavorite(row.stableKey) }, - ) - } - item { Spacer(modifier = Modifier.height(GlowDimens.SPACE_4.dp)) } - } -} - -@Composable -private fun ModMenuModuleRow( - row: ModRow, - onToggle: (Boolean) -> Unit, - onFavorite: () -> Unit, -) { - val colors = glowColors - val accent = MaterialTheme.colorScheme.primary - val unavailable = row.unavailableReason != null - val shape = RoundedCornerShape(GlowDimens.RADIUS_LARGE.dp) - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(shape) - .background(Color.White.copy(alpha = 0.045f)) - .border(1.dp, Color.White.copy(alpha = 0.09f), shape) - .alpha(if (unavailable) 0.55f else 1f) - .padding( - horizontal = GlowDimens.SPACE_4.dp, - vertical = GlowDimens.SPACE_3.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_3.dp), - ) { - Icon( - imageVector = Icons.Outlined.CheckBox, - contentDescription = null, - tint = if (row.enabled) accent else colors.textDisabled, - modifier = Modifier.size(20.dp), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = row.name, - style = MaterialTheme.typography.labelMedium, - color = colors.textPrimary, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = row.unavailableReason ?: row.description.ifEmpty { - stringResource(R.string.mod_menu_no_description) - }, - style = MaterialTheme.typography.labelSmall, - color = if (unavailable) colors.warning else colors.textSecondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton(onClick = onFavorite, modifier = Modifier.size(28.dp)) { - Icon( - imageVector = if (row.favorite) Icons.Filled.Star else Icons.Outlined.StarBorder, - contentDescription = stringResource(R.string.mod_menu_favorites), - tint = if (row.favorite) accent else colors.textDisabled, - modifier = Modifier.size(18.dp), - ) - } - Switch( - checked = row.enabled, - onCheckedChange = if (unavailable) null else onToggle, - enabled = !unavailable, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = accent, - uncheckedThumbColor = colors.textDisabled, - uncheckedTrackColor = Color.White.copy(alpha = 0.12f), - ), - ) - } -} - -/** The Save destination: the saved configurations the player can switch to at once. */ -@Composable -private fun ModMenuSaveList(state: ModMenuState) { - val colors = glowColors - if (state.loadouts.isEmpty()) { - ModMenuPlaceholder( - icon = Icons.Outlined.Save, - text = stringResource(R.string.mod_menu_save_empty), - secondary = stringResource(R.string.mod_menu_save_hint), - ) - return - } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = GlowDimens.SPACE_4.dp), - verticalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_2.dp), - ) { - items(state.loadouts, key = { it.id }) { loadout -> - val shape = RoundedCornerShape(GlowDimens.RADIUS_LARGE.dp) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(shape) - .background(Color.White.copy(alpha = 0.045f)) - .border(1.dp, Color.White.copy(alpha = 0.09f), shape) - .padding(GlowDimens.SPACE_4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_3.dp), - ) { - Icon( - imageVector = Icons.Outlined.Save, - contentDescription = null, - tint = colors.textSecondary, - modifier = Modifier.size(20.dp), - ) - Text( - text = loadout.name, - style = MaterialTheme.typography.labelMedium, - color = colors.textPrimary, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = stringResource(R.string.mod_menu_module_count, loadout.enabledCount), - style = MaterialTheme.typography.labelSmall, - color = colors.textSecondary, - ) - } - } - } -} - -/** The Settings destination: the menu's own toggles and sliders. */ -@Composable -private fun ModMenuSettingsList( - state: ModMenuState, - onToggleSetting: (String, Boolean) -> Unit, -) { - val colors = glowColors - val accent = MaterialTheme.colorScheme.primary - if (state.settings.isEmpty()) { - ModMenuPlaceholder( - icon = Icons.Outlined.Tune, - text = stringResource(R.string.mod_menu_tab_settings), - ) - return - } - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = GlowDimens.SPACE_4.dp), - verticalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_2.dp), - ) { - items(state.settings, key = { it.key }) { row -> - val shape = RoundedCornerShape(GlowDimens.RADIUS_LARGE.dp) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(shape) - .background(Color.White.copy(alpha = 0.045f)) - .border(1.dp, Color.White.copy(alpha = 0.09f), shape) - .padding( - horizontal = GlowDimens.SPACE_4.dp, - vertical = GlowDimens.SPACE_3.dp, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_3.dp), - ) { - Text( - text = row.label, - style = MaterialTheme.typography.labelMedium, - color = colors.textPrimary, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (row.kind == SettingsKind.TOGGLE) { - val on = row.value.equals("true", ignoreCase = true) - Switch( - checked = on, - onCheckedChange = { onToggleSetting(row.key, it) }, - colors = SwitchDefaults.colors( - checkedThumbColor = Color.White, - checkedTrackColor = accent, - uncheckedThumbColor = colors.textDisabled, - uncheckedTrackColor = Color.White.copy(alpha = 0.12f), - ), - ) - } else { - Text( - text = row.value, - style = MaterialTheme.typography.labelSmall, - color = colors.textSecondary, - ) - } - } - } - } -} - -/** The shared empty/placeholder state. */ -@Composable -private fun ModMenuPlaceholder( - icon: ImageVector, - text: String, - secondary: String? = null, -) { - val colors = glowColors - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(GlowDimens.SPACE_3.dp), - modifier = Modifier.padding(GlowDimens.SPACE_6.dp), - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colors.textDisabled, - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.05f)) - .padding(10.dp), - ) - Text( - text = text, - style = MaterialTheme.typography.labelMedium, - color = colors.textSecondary, - ) - if (secondary != null) { - Text( - text = secondary, - style = MaterialTheme.typography.labelSmall, - color = colors.textDisabled, - ) - } - } - } -} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuHost.kt b/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuHost.kt deleted file mode 100644 index 447d34a4..00000000 --- a/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuHost.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.chimeramc.client.ui.compose.modmenu - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.platform.ComposeView -import org.chimeramc.client.ui.compose.theme.GlowTheme - -/** - * The Java-facing entry point for the Compose Mod Menu. - * - *

The overlay (Java) owns the mod list and the persistence; it builds a [ModMenuState] and hosts - * it here. Everything Compose-shaped stays in Kotlin, and every interaction comes back as a - * callback, so the Java side keeps its existing `InbuiltModManager` wiring rather than being - * rewritten to talk to Compose directly. - */ -object ModMenuHost { - - @JvmStatic - fun state(initial: ModMenuState): MutableState = mutableStateOf(initial) - - @JvmStatic - fun setContent( - composeView: ComposeView, - state: MutableState, - onDestination: java.util.function.Consumer, - onSearch: java.util.function.Consumer, - onToggleModule: ModuleToggleListener, - onToggleFavorite: java.util.function.Consumer, - onToggleFavoritesOnly: Runnable, - onToggleSetting: SettingToggleListener, - onClose: Runnable, - ) { - composeView.setContent { - GlowTheme { - ModMenu( - state = state.value, - onDestination = { onDestination.accept(it) }, - onSearch = { onSearch.accept(it) }, - onToggleModule = { key, enabled -> onToggleModule.onToggle(key, enabled) }, - onToggleFavorite = { onToggleFavorite.accept(it) }, - onToggleFavoritesOnly = { onToggleFavoritesOnly.run() }, - onToggleSetting = { key, enabled -> onToggleSetting.onToggle(key, enabled) }, - onClose = { onClose.run() }, - ) - } - } - } - - /** A module toggle, as a Java functional interface so the overlay can pass a lambda. */ - fun interface ModuleToggleListener { - fun onToggle(stableKey: String, enabled: Boolean) - } - - /** A settings toggle, as a Java functional interface. */ - fun interface SettingToggleListener { - fun onToggle(key: String, enabled: Boolean) - } -} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuState.kt b/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuState.kt deleted file mode 100644 index 3c969acf..00000000 --- a/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuState.kt +++ /dev/null @@ -1,114 +0,0 @@ -package org.chimeramc.client.ui.compose.modmenu - -import androidx.compose.runtime.Immutable - -/** - * The Mod Menu's four destinations, in the order the sidebar shows them. - * - * The menu used to be a nine-page rail where the page set was itself a bug surface (a module could - * live on one page and not another). The rebuild is four destinations, and the module catalogue is - * one list filtered inside the Modules destination rather than spread across pages. - */ -enum class ModMenuDestination(val labelRes: Int) { - MODULES(org.chimeramc.client.R.string.mod_menu_tab_modules), - COSMETICS(org.chimeramc.client.R.string.mod_menu_tab_cosmetics), - SAVE(org.chimeramc.client.R.string.mod_menu_tab_save), - SETTINGS(org.chimeramc.client.R.string.mod_menu_tab_settings), -} - -/** One configurable entry of a module, flattened for the list UI. */ -@Immutable -data class ModConfigRow( - val key: String, - val label: String, - val value: String, - val isToggle: Boolean, -) - -/** One module row. [stableKey] is the identity the toggle and the favorites set key on. */ -@Immutable -data class ModRow( - val stableKey: String, - val name: String, - val description: String, - val groupName: String, - val enabled: Boolean, - val favorite: Boolean, - val hasConfig: Boolean, - /** Non-null when the module cannot run in this build; the row is badged and its toggle refused. */ - val unavailableReason: String?, - val configs: List, -) - -/** A saved configuration (a whole loadout the player can switch to at once). */ -@Immutable -data class SavedLoadout( - val id: String, - val name: String, - val enabledCount: Int, -) - -/** One row of the Settings destination. */ -@Immutable -data class SettingsRow( - val key: String, - val label: String, - val value: String, - val kind: SettingsKind, -) - -enum class SettingsKind { TOGGLE, SLIDER, CHOICE } - -/** The whole Mod Menu state. Rebuilt on the Java side and pushed in as one immutable value. */ -@Immutable -data class ModMenuState( - val destination: ModMenuDestination = ModMenuDestination.MODULES, - val title: String = "", - val rows: List = emptyList(), - val loadouts: List = emptyList(), - val settings: List = emptyList(), - val search: String = "", - val favoritesOnly: Boolean = false, - /** The glass tint the player chose, as an ARGB int; 0 means "use the theme surface". */ - val glassTint: Int = 0, - val opacityPercent: Int = 25, -) { - /** The rows after the search box and the favorites filter, which is what the list draws. */ - val visibleRows: List - get() { - val query = search.trim().lowercase() - return rows.filter { row -> - (!favoritesOnly || row.favorite) && - (query.isEmpty() || - row.name.lowercase().contains(query) || - row.description.lowercase().contains(query) || - row.groupName.lowercase().contains(query)) - } - } - - val activeCount: Int get() = rows.count { it.enabled } - - // Java-facing helpers. The generated `copy` takes nine positional arguments, which is easy to - // mis-order from Java; these name the one field each changes, so the overlay cannot transpose - // two of them. - fun withDestination(destination: ModMenuDestination): ModMenuState = - copy(destination = destination) - - fun withSearch(search: String): ModMenuState = copy(search = search) - - fun withFavoritesOnly(favoritesOnly: Boolean): ModMenuState = - copy(favoritesOnly = favoritesOnly) - - fun withRows(rows: List): ModMenuState = copy(rows = rows) - - fun withSettings(settings: List): ModMenuState = copy(settings = settings) - - fun withLoadouts(loadouts: List): ModMenuState = copy(loadouts = loadouts) - - /** Rebuilds the row list from a fresh catalogue, keeping the destination and the filters. */ - fun withCatalogue( - rows: List, - loadouts: List, - settings: List, - ): ModMenuState = copy(rows = rows, loadouts = loadouts, settings = settings) -} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuStateBuilder.java b/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuStateBuilder.java deleted file mode 100644 index 250a350b..00000000 --- a/app/src/main/java/org/chimeramc/client/ui/compose/modmenu/ModMenuStateBuilder.java +++ /dev/null @@ -1,102 +0,0 @@ -package org.chimeramc.client.ui.compose.modmenu; - -import org.chimeramc.client.core.mods.inbuilt.UnifiedMod; -import org.chimeramc.client.core.mods.inbuilt.model.ModAvailability; - -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; - -/** - * Maps the launcher's mod model onto the Compose Mod Menu's immutable state. - * - *

The overlay owns the {@link UnifiedMod} list and the persistence; this is the one place that - * knows how a mod becomes a row, so the Compose UI never touches a {@code UnifiedMod} and the - * mapping (including the availability badge and the favorite flag) is unit-testable on the JVM. - */ -public final class ModMenuStateBuilder { - - private ModMenuStateBuilder() { - } - - /** Builds one row from a mod. Pure: it reads the mod and the favorites set, nothing else. */ - public static ModRow toRow(UnifiedMod mod, boolean favorite) { - List configs = new ArrayList<>(); - for (UnifiedMod.ConfigEntry entry : mod.getConfigEntries()) { - configs.add(new ModConfigRow( - entry.key, - entry.displayName == null ? entry.key : entry.displayName, - mod.getConfigValue(entry.key, entry.defaultValue == null ? "" : entry.defaultValue), - entry.type == UnifiedMod.ConfigType.TOGGLE)); - } - return new ModRow( - mod.getStableKey(), - mod.getName() == null ? "" : mod.getName(), - mod.getDescription() == null ? "" : mod.getDescription(), - mod.getGroupName() == null ? "" : mod.getGroupName(), - mod.isEnabled(), - favorite, - mod.hasConfig(), - ModAvailability.unavailableReason(mod.getId()), - configs); - } - - /** - * Builds the whole state from the mod list and the pinned keys. - * - * @param mods the mods to show, in the order the list should show them - * @param favoriteKeys the pinned stable keys - * @param destination the destination to open on - */ - public static ModMenuState build(List mods, - java.util.Set favoriteKeys, - ModMenuDestination destination) { - List rows = new ArrayList<>(); - if (mods != null) { - for (UnifiedMod mod : mods) { - if (mod == null) continue; - boolean favorite = favoriteKeys != null && favoriteKeys.contains(mod.getStableKey()); - rows.add(toRow(mod, favorite)); - } - } - return new ModMenuState( - destination, - "", - rows, - java.util.Collections.emptyList(), - java.util.Collections.emptyList(), - "", - false, - 0, - 25); - } - - /** - * Flips a module's enabled state and returns the new value, so the caller can persist it. - * - *

The overlay keeps its own persistence, so this only drives the model; a module that cannot - * run in this build is refused here as well as in the UI, so a caller that bypasses the switch - * still cannot enable something that would do nothing. - */ - public static boolean toggle(UnifiedMod mod, boolean enabled) { - if (mod == null) return false; - if (enabled && ModAvailability.isUnavailable(mod.getId())) { - return mod.isEnabled(); - } - mod.applyEnabled(enabled); - return enabled; - } - - /** The row key for a module, for a lookup by the stable key the UI passes back. */ - public static boolean matchesKey(UnifiedMod mod, String stableKey) { - return mod != null && stableKey != null - && stableKey.equalsIgnoreCase(mod.getStableKey()); - } - - /** A lowercase search key for a mod, matching what the Compose filter searches. */ - public static String searchKey(UnifiedMod mod) { - if (mod == null) return ""; - return (mod.getName() + " " + mod.getDescription() + " " + mod.getGroupName()) - .toLowerCase(Locale.ROOT); - } -} diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/theme/GlowPalette.java b/app/src/main/java/org/chimeramc/client/ui/compose/theme/GlowPalette.java index 772f7764..288f2d16 100644 --- a/app/src/main/java/org/chimeramc/client/ui/compose/theme/GlowPalette.java +++ b/app/src/main/java/org/chimeramc/client/ui/compose/theme/GlowPalette.java @@ -20,46 +20,61 @@ private GlowPalette() { // --- Navy ramp --------------------------------------------------------------------------- - /** The window: the deepest step, a near-black navy. */ - public static final int NAVY_WINDOW = 0xFF0A0E16; - /** A panel or card surface, one step above the window. */ - public static final int NAVY_SURFACE = 0xFF111826; - /** A raised surface (a row inside a card, a hovered panel). */ - public static final int NAVY_SURFACE_RAISED = 0xFF18213A; + /** + * The window: the pack's {@code Abyss}. The deepest step, a near-black navy — never pure black, + * because on an LCD "OLED harsh" is a defect rather than a feature. + */ + public static final int NAVY_WINDOW = 0xFF060B1A; + /** A panel or card surface, one step above the window: the pack's {@code Navy}. */ + public static final int NAVY_SURFACE = 0xFF0A1330; + /** A raised surface (a row inside a card, a hovered panel): the pack's {@code DeepBlue}. */ + public static final int NAVY_SURFACE_RAISED = 0xFF0F1B44; /** The hairline that separates two navy surfaces. */ - public static final int NAVY_STROKE = 0xFF223052; + public static final int NAVY_STROKE = 0xFF22305A; /** The AMOLED window: true black, for the burn-in-conscious theme. */ public static final int AMOLED_WINDOW = 0xFF000000; public static final int AMOLED_SURFACE = 0xFF070A11; public static final int AMOLED_SURFACE_RAISED = 0xFF101724; - // --- Snow blue (the toggle + accent family) ---------------------------------------------- + // --- Blue (the accent family) ------------------------------------------------------------ - /** Snow blue: the toggle track when on, the primary action, the selected state. */ - public static final int SNOW_BLUE = 0xFFB8D8F5; - /** A step lighter, for hover and the toggle thumb. */ - public static final int SNOW_BLUE_LIGHT = 0xFFDCEBFA; - /** A step deeper, for pressed and for text on a snow-blue fill. */ - public static final int SNOW_BLUE_DEEP = 0xFF6FA8DC; + /** + * The pack's {@code Blue}: the toggle track when on, the primary action, the selected state. + * + *

The pack's toggle-on is its green, not its blue, but the launcher's toggle is the accent — + * one accent per screen is the rule the mod menu's palette states, and the launch toggles are + * the accent surface. The pack's blue is still the accent the rest of the UI uses. + */ + public static final int SNOW_BLUE = 0xFF4A78FF; + /** A step lighter, for hover and the toggle thumb: the pack's {@code BlueLight}. */ + public static final int SNOW_BLUE_LIGHT = 0xFF628CFF; + /** A step deeper, for pressed and for text on a blue fill. */ + public static final int SNOW_BLUE_DEEP = 0xFF3458E6; /** The washed fill behind a selected row. */ - public static final int SNOW_BLUE_WASH = 0xFF1A2740; + public static final int SNOW_BLUE_WASH = 0xFF152252; /** The toggle track when off: a muted navy, never grey. */ public static final int TOGGLE_TRACK_OFF = 0xFF232C40; // --- Text -------------------------------------------------------------------------------- - public static final int TEXT_PRIMARY = 0xFFE9EEF7; - public static final int TEXT_SECONDARY = 0xFF9FB0C9; - public static final int TEXT_DISABLED = 0xFF5C6B84; - /** Text drawn on a snow-blue fill. */ - public static final int TEXT_ON_SNOW = 0xFF0A0E16; + /** The pack's {@code Text}. */ + public static final int TEXT_PRIMARY = 0xFFEAF0FF; + /** The pack's {@code Text2}. */ + public static final int TEXT_SECONDARY = 0xFF9AA8D0; + /** The pack's {@code Text3}. */ + public static final int TEXT_DISABLED = 0xFF66749E; + /** Text drawn on a blue fill. */ + public static final int TEXT_ON_SNOW = 0xFF060B1A; // --- Status ------------------------------------------------------------------------------ - public static final int SUCCESS = 0xFF4ADE80; - public static final int WARNING = 0xFFFBBF5C; - public static final int DANGER = 0xFFFF6B6B; + /** The pack's {@code Green}. */ + public static final int SUCCESS = 0xFF2FD36B; + /** The pack's {@code Orange}. */ + public static final int WARNING = 0xFFFF9F2E; + /** The pack's {@code Red}. */ + public static final int DANGER = 0xFFFF5468; // --- Glass (the shared card material) ---------------------------------------------------- diff --git a/app/src/main/res/drawable/bg_split_launch.xml b/app/src/main/res/drawable/bg_split_launch.xml new file mode 100644 index 00000000..cfd32109 --- /dev/null +++ b/app/src/main/res/drawable/bg_split_launch.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_split_launch_chevron.xml b/app/src/main/res/drawable/bg_split_launch_chevron.xml new file mode 100644 index 00000000..a364cbdb --- /dev/null +++ b/app/src/main/res/drawable/bg_split_launch_chevron.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_mod_menu_bind.xml b/app/src/main/res/layout/dialog_mod_menu_bind.xml deleted file mode 100644 index 52f8e2af..00000000 --- a/app/src/main/res/layout/dialog_mod_menu_bind.xml +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - -