From d3789ec57f58ec7e4c8ede251ecaac524b0b286b Mon Sep 17 00:00:00 2001 From: Lynxifyy <297587416+Lynxifyy@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:41:18 +0000 Subject: [PATCH] feat(ui): mount the Caramel UI pack's Compose launcher and its native Mod Menu The legacy launcher UI was deleted so the app could become a clean skeleton. This puts a real UI back: the Caramel UI pack's Compose launcher, wired to the managers that survived the deletion. The launcher ------------ MainActivity hosts a single ComposeView; CaramelLauncherHost is the Java-facing seam (the activity supplies the Actions, the Kotlin side supplies the frame). The pack's frame, glass title bar, 56dp sidebar rail and six tabs (Home, Mods, Servers, Cosmetics, News, Store) are ported under ui/compose/caramel. Every row is real data. The pack ships Versions, ExampleMods and ExampleServers for its demo; those are not carried over. CaramelLauncherState defaults every list to empty and the activity fills it from VersionManager, MsftAccountStore and ModManager, so a screen with nothing to show draws its empty state rather than a fabricated row. The Store tab has no backend, so it says so. The in-game Mod Menu -------------------- The menu the launcher opens is the pack's Dear ImGui menu, restored as a native overlay drawn on the game's GL context (sweetloader, pl::menu). InGameMenuController is the bridge: it builds the module catalogue from the same providers the launcher uses, forwards touch and the back button into the menu, and applies the events it raises onto the real modules on the main thread. PreloaderMenu is the JNI seam and is fail-closed: with no live present hook the menu reports itself unavailable and cannot open. MinecraftActivity starts the controller with the session, gives the open menu first refusal on every touch, and drains its events each tick. --- app/src/main/cpp/sweetloader | 2 +- .../core/minecraft/MinecraftActivity.kt | 15 + .../inbuilt/overlay/InGameMenuController.java | 402 ++++++++++++++++++ .../overlay/InbuiltOverlayManager.java | 26 +- .../launcher/preloader/PreloaderMenu.java | 142 +++++++ .../launcher/ui/activities/MainActivity.java | 205 ++++++++- .../ui/compose/caramel/CaramelControls.kt | 328 ++++++++++++++ .../ui/compose/caramel/CaramelDropdowns.kt | 221 ++++++++++ .../client/ui/compose/caramel/CaramelGlass.kt | 163 +++++++ .../ui/compose/caramel/CaramelLabels.kt | 176 ++++++++ .../ui/compose/caramel/CaramelLoaderIcons.kt | 112 +++++ .../ui/compose/caramel/CaramelModels.kt | 120 ++++++ .../client/ui/compose/caramel/CaramelTheme.kt | 127 ++++++ .../client/ui/compose/caramel/CaramelUtil.kt | 7 + .../caramel/launcher/CaramelCosmeticsStore.kt | 231 ++++++++++ .../compose/caramel/launcher/CaramelHome.kt | 276 ++++++++++++ .../caramel/launcher/CaramelLauncherHost.kt | 69 +++ .../caramel/launcher/CaramelLauncherScreen.kt | 49 +++ .../caramel/launcher/CaramelModsServers.kt | 227 ++++++++++ .../caramel/launcher/CaramelOverlays.kt | 156 +++++++ .../compose/caramel/launcher/CaramelShell.kt | 174 ++++++++ .../ModUnavailableReasonResourcesTest.java | 85 ++++ 22 files changed, 3301 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/InGameMenuController.java create mode 100644 app/src/main/java/org/chimeramc/client/launcher/preloader/PreloaderMenu.java create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelControls.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelDropdowns.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelGlass.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLabels.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLoaderIcons.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelModels.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelTheme.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelUtil.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelCosmeticsStore.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelHome.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherHost.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelLauncherScreen.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelModsServers.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelOverlays.kt create mode 100644 app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelShell.kt create mode 100644 app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModUnavailableReasonResourcesTest.java diff --git a/app/src/main/cpp/sweetloader b/app/src/main/cpp/sweetloader index 3fda389f..4ff2e63e 160000 --- a/app/src/main/cpp/sweetloader +++ b/app/src/main/cpp/sweetloader @@ -1 +1 @@ -Subproject commit 3fda389f6975cab0e8ae375a94227151470d0ed1 +Subproject commit 4ff2e63e9bb81f1d5cc605078d41df71f8a229d2 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 a09ed3c6..c805185e 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) { @@ -575,6 +579,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 } @@ -861,6 +872,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/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 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 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 6a20171b..50bca065 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
@@ -1,28 +1,219 @@
package org.chimeramc.client.ui.activities;
+import android.content.Intent;
import android.os.Bundle;
import androidx.annotation.Nullable;
+import androidx.compose.runtime.MutableState;
+import androidx.compose.ui.platform.ComposeView;
+import androidx.compose.ui.platform.ViewCompositionStrategy;
+
+import org.chimeramc.client.core.auth.MsftAccountStore;
+import org.chimeramc.client.core.minecraft.MinecraftActivityState;
+import org.chimeramc.client.core.minecraft.MinecraftLauncher;
+import org.chimeramc.client.core.mods.Mod;
+import org.chimeramc.client.core.mods.ModManager;
+import org.chimeramc.client.core.versions.GameVersion;
+import org.chimeramc.client.core.versions.VersionManager;
+import org.chimeramc.client.ui.compose.caramel.CaramelCosmeticInfo;
+import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState;
+import org.chimeramc.client.ui.compose.caramel.CaramelModInfo;
+import org.chimeramc.client.ui.compose.caramel.CaramelServerInfo;
+import org.chimeramc.client.ui.compose.caramel.CaramelVersion;
+import org.chimeramc.client.ui.compose.caramel.launcher.CaramelLauncherHost;
+import org.chimeramc.client.util.AppLog;
+
+import java.util.ArrayList;
+import java.util.List;
/**
- * Placeholder screen.
+ * The launcher's main screen: the Caramel UI pack's Compose launcher, hosted in a {@link ComposeView}.
*
- * The legacy UI was removed in one sweep so a new one can be dropped in. This class survives
- * only because non-UI code (the manifest, an Intent target, or a nav destination) still names it;
- * it draws nothing. Replace it with the real MainActivity screen.
+ * This activity owns the state and the actions. It builds a {@link CaramelLauncherState} from the
+ * live managers (versions, account, mods) and hands it to the Kotlin host, which renders the pack's
+ * frame, tabs and modals. Every button and slider on screen calls back into a real launcher action
+ * here; nothing is a mock.
+ *
+ * The activity draws no chrome of its own. {@link BaseActivity} keeps the immersive full-screen
+ * plumbing, and the Compose surface fills what is left.
*/
-public class MainActivity extends BaseActivity {
+public class MainActivity extends BaseActivity implements CaramelLauncherHost.Actions {
+
+ private static final String TAG = "MainActivity";
+
+ private MutableState The pack styles its buttons on hover, which a touch screen never does. `collectIsHoveredAsState`
+ * is replaced by `collectIsPressedAsState`, so the same brightening happens under a finger instead
+ * of a cursor; everything else (the tone ramps, the shadow, the top-edge highlight) is the pack's.
+ */
+
+/** The pack's `Tone` ramps: a vertical gradient plus the colour text sits on. */
+enum class Tone(val top: Color, val bottom: Color, val on: Color) {
+ Green(C.GreenLight, C.GreenDark, C.OnGreen),
+ Caramel(Color(0xFFFFBE5A), C.CaramelDark, Color.White),
+ Blue(C.BlueLight, C.BlueDark, Color.White),
+ Red(Color(0xFFFF6C7E), Color(0xFFD6344E), Color.White),
+}
+
+/** The pack's `PrimaryButton`: a filled, gradient, glowing action. */
+@Composable
+fun PrimaryButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ tone: Tone = Tone.Green,
+ icon: ImageVector? = null,
+ height: Int = 44,
+) {
+ val src = remember { MutableInteractionSource() }
+ val pressed by src.collectIsPressedAsState()
+ val shape = RoundedCornerShape((height / 2.6).dp)
+ Box(
+ modifier
+ .height(height.dp)
+ .shadow(if (pressed) 16.dp else 10.dp, shape, ambientColor = tone.bottom, spotColor = tone.bottom)
+ .clip(shape)
+ .background(Brush.verticalGradient(listOf(tone.top, tone.bottom)))
+ .background(
+ Brush.verticalGradient(
+ listOf(Color.White.copy(if (pressed) 0.28f else 0.20f), Color.Transparent, Color.Transparent),
+ ),
+ )
+ .border(1.dp, Color.White.copy(0.28f), shape)
+ .clickable(src, null, onClick = onClick),
+ Alignment.Center,
+ ) {
+ Row(
+ Modifier.padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (icon != null) Icon(icon, null, Modifier.size(18.dp), tone.on)
+ Text(text, style = T.Button.copy(color = tone.on))
+ }
+ }
+}
+
+/** The pack's `GlassButton`: a translucent secondary action. `text == null` makes it square. */
+@Composable
+fun GlassButton(
+ text: String?,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ icon: ImageVector? = null,
+ danger: Boolean = false,
+ active: Boolean = false,
+ height: Int = 40,
+) {
+ val src = remember { MutableInteractionSource() }
+ val pressed by src.collectIsPressedAsState()
+ val tint = when {
+ danger -> C.Red
+ active -> C.Blue
+ else -> Color.White
+ }
+ val shape = RoundedCornerShape(CaramelDimens.RadiusButton)
+ val fg = if (danger) C.Red else C.Text
+ Box(
+ modifier
+ .height(height.dp)
+ .then(if (text == null) Modifier.width(height.dp) else Modifier)
+ .clip(shape)
+ .background(tint.copy(if (danger || active) 0.16f else if (pressed) 0.14f else 0.08f))
+ .border(1.dp, tint.copy(if (danger || active) 0.45f else if (pressed) 0.28f else 0.17f), shape)
+ .clickable(src, null, onClick = onClick),
+ Alignment.Center,
+ ) {
+ Row(
+ Modifier.padding(horizontal = if (text == null) 0.dp else 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (icon != null) Icon(icon, null, Modifier.size(17.dp), fg)
+ if (text != null) Text(text, style = T.Label.copy(color = fg))
+ }
+ }
+}
+
+/** The pack's `CaramelToggle`: a gradient track with a sliding thumb. */
+@Composable
+fun CaramelToggle(checked: Boolean, onChange: (Boolean) -> Unit, modifier: Modifier = Modifier) {
+ val x by animateDpAsState(if (checked) 21.dp else 3.dp, spring(stiffness = 600f))
+ val shape = RoundedCornerShape(50)
+ Box(
+ modifier
+ .size(40.dp, 22.dp)
+ .clip(shape)
+ .background(
+ if (checked) {
+ Brush.horizontalGradient(listOf(C.GreenLight, C.GreenDark))
+ } else {
+ SolidColor(Color.White.copy(0.10f))
+ },
+ )
+ .border(1.dp, if (checked) Color.Transparent else Color.White.copy(0.24f), shape)
+ .clickable(remember { MutableInteractionSource() }, null) { onChange(!checked) },
+ ) {
+ Box(
+ Modifier
+ .offset(x, 3.dp)
+ .size(16.dp)
+ .shadow(3.dp, CircleShape)
+ .background(if (checked) Color.White else Color(0xFFBECAEB), CircleShape),
+ )
+ }
+}
+
+/** The pack's `CaramelCheckbox`. */
+@Composable
+fun CaramelCheckbox(checked: Boolean, onChange: (Boolean) -> Unit, modifier: Modifier = Modifier) {
+ val shape = RoundedCornerShape(5.dp)
+ Box(
+ modifier
+ .size(18.dp)
+ .clip(shape)
+ .background(
+ if (checked) {
+ Brush.verticalGradient(listOf(C.BlueLight, C.BlueDark))
+ } else {
+ SolidColor(Color.White.copy(0.06f))
+ },
+ )
+ .border(1.dp, if (checked) Color.Transparent else Color.White.copy(0.28f), shape)
+ .clickable(remember { MutableInteractionSource() }, null) { onChange(!checked) },
+ Alignment.Center,
+ ) {
+ if (checked) Icon(Icons.Rounded.Check, null, Modifier.size(13.dp), Color.White)
+ }
+}
+
+/** The pack's `CaramelSlider`: a gradient track and a white knob with an accent core. */
+@Composable
+fun CaramelSlider(
+ value: Float,
+ onChange: (Float) -> Unit,
+ modifier: Modifier = Modifier,
+ accent: Color = C.Blue,
+ accentEnd: Color = Color(0xFF4ABEFF),
+) {
+ var w by remember { mutableStateOf(1) }
+ Canvas(
+ modifier
+ .fillMaxWidth()
+ .height(22.dp)
+ .onSizeChanged { w = it.width.coerceAtLeast(1) }
+ .pointerInput(Unit) {
+ detectTapGestures { onChange((it.x / w).coerceIn(0f, 1f)) }
+ }
+ .pointerInput(Unit) {
+ detectDragGestures { c, _ -> onChange((c.position.x / w).coerceIn(0f, 1f)) }
+ },
+ ) {
+ val cy = size.height / 2
+ val th = 6.dp.toPx()
+ val kx = size.width * value
+ drawRoundRect(Color.White.copy(0.12f), Offset(0f, cy - th / 2), Size(size.width, th), CornerRadius(th))
+ drawRoundRect(Brush.horizontalGradient(listOf(accent, accentEnd)), Offset(0f, cy - th / 2), Size(kx, th), CornerRadius(th))
+ drawCircle(Color.Black.copy(0.35f), 9.dp.toPx(), Offset(kx, cy + 1.5.dp.toPx()))
+ drawCircle(Color.White, 8.dp.toPx(), Offset(kx, cy))
+ drawCircle(accent, 3.dp.toPx(), Offset(kx, cy))
+ }
+}
+
+/** The pack's `CaramelTextField`, with a search leading icon for [SearchField]. */
+@Composable
+fun CaramelTextField(
+ value: String,
+ onChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ placeholder: String = "",
+ leading: ImageVector? = null,
+) {
+ var focused by remember { mutableStateOf(false) }
+ val border by animateColorAsState(if (focused) C.Blue.copy(0.8f) else Color.White.copy(0.16f))
+ val shape = RoundedCornerShape(CaramelDimens.RadiusControl)
+ BasicTextField(
+ value,
+ onChange,
+ modifier
+ .height(38.dp)
+ .clip(shape)
+ .background(Color(0x6E040A1A))
+ .border(1.dp, border, shape)
+ .onFocusChangedCompat { focused = it },
+ singleLine = true,
+ textStyle = T.Label.copy(fontWeight = FontWeight.Normal),
+ cursorBrush = SolidColor(C.Blue),
+ decorationBox = { inner ->
+ Row(
+ Modifier
+ .padding(horizontal = 14.dp)
+ .fillMaxHeight(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ if (leading != null) Icon(leading, null, Modifier.size(16.dp), C.Text3)
+ Box(Modifier.weight(1f)) {
+ if (value.isEmpty()) Text(placeholder, style = T.Label.copy(color = C.Text3, fontWeight = FontWeight.Normal))
+ inner()
+ }
+ }
+ },
+ )
+}
+
+/** A search box, the pack's `SearchField`. */
+@Composable
+fun SearchField(
+ value: String,
+ onChange: (String) -> Unit,
+ placeholder: String,
+ modifier: Modifier = Modifier,
+) = CaramelTextField(value, onChange, modifier, placeholder, Icons.Rounded.Search)
+
+/** A title, an optional description and a trailing control, from the pack's `SettingRow`. */
+@Composable
+fun SettingRow(
+ title: String,
+ desc: String? = null,
+ modifier: Modifier = Modifier,
+ control: @Composable () -> Unit,
+) {
+ Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(title, style = T.Label)
+ if (desc != null) Text(desc, style = T.Body)
+ }
+ control()
+ }
+}
+
+/** A labelled slider whose value is shown in the accent colour, from the pack's `SliderRow`. */
+@Composable
+fun SliderRow(
+ title: String,
+ valueText: String,
+ value: Float,
+ onChange: (Float) -> Unit,
+ accent: Color = C.Blue,
+ accentEnd: Color = Color(0xFF4ABEFF),
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row {
+ Text(title, style = T.Label, modifier = Modifier.weight(1f))
+ Text(valueText, style = T.Label.copy(color = accent))
+ }
+ CaramelSlider(value, onChange, accent = accent, accentEnd = accentEnd)
+ }
+}
+
+/** Local sp helpers, so call sites can write `12.sp_` next to the pack's own literals. */
+val Int.sp_: androidx.compose.ui.unit.TextUnit
+ get() = androidx.compose.ui.unit.TextUnit(this.toFloat(), androidx.compose.ui.unit.TextUnitType.Sp)
+
+val Double.sp_: androidx.compose.ui.unit.TextUnit
+ get() = androidx.compose.ui.unit.TextUnit(this.toFloat(), androidx.compose.ui.unit.TextUnitType.Sp)
diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelDropdowns.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelDropdowns.kt
new file mode 100644
index 00000000..e864760b
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelDropdowns.kt
@@ -0,0 +1,221 @@
+package org.chimeramc.client.ui.compose.caramel
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+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.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material.icons.rounded.KeyboardArrowUp
+import androidx.compose.material.icons.rounded.SwapHoriz
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
+
+/**
+ * The Caramel dropdowns, ported from the pack's `components/Dropdowns.kt`.
+ *
+ * A closed trigger plus a glass popup. Compose's `Popup` exists on Android, so the pack's
+ * mechanism ports unchanged; only the glass surface underneath it is the Android [GlassPanel]
+ * rather than the pack's Haze one.
+ */
+
+/** A closed dropdown trigger plus a generic glass popup, anchored under the trigger. */
+@Composable
+fun CaramelDropdown(
+ value: String,
+ modifier: Modifier = Modifier,
+ width: Int = 180,
+ panelWidth: Int = width,
+ panel: @Composable (close: () -> Unit) -> Unit,
+) {
+ var open by remember { mutableStateOf(false) }
+ Box(modifier) {
+ CaramelDropdownField(value, open, Modifier.width(width.dp)) { open = !open }
+ if (open) {
+ Popup(
+ alignment = Alignment.TopStart,
+ offset = IntOffset(0, 46),
+ onDismissRequest = { open = false },
+ properties = PopupProperties(focusable = true),
+ ) {
+ GlassPanel(Modifier.width(panelWidth.dp), RoundedCornerShape(16.dp), popup = true, elevation = 32.dp) {
+ panel { open = false }
+ }
+ }
+ }
+ }
+}
+
+/** The closed trigger itself: a value, a chevron, and an open/closed border. */
+@Composable
+fun CaramelDropdownField(text: String, open: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
+ val shape = RoundedCornerShape(CaramelDimens.RadiusControl)
+ Row(
+ modifier
+ .height(38.dp)
+ .clip(shape)
+ .background(Color.White.copy(if (open) 0.13f else 0.08f))
+ .border(1.dp, if (open) C.Blue.copy(0.7f) else Color.White.copy(0.18f), shape)
+ .clickable(remember { MutableInteractionSource() }, null, onClick = onClick)
+ .padding(horizontal = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(text, style = T.Label.copy(fontWeight = FontWeight.Medium), modifier = Modifier.weight(1f))
+ Icon(if (open) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown, null, Modifier.size(18.dp), C.Text2)
+ }
+}
+
+/** A plain string list dropdown. */
+@Composable
+fun CaramelSimpleDropdown(
+ value: String,
+ options: List The pack filters a hardcoded list; this filters the list the activity supplies, so the picker
+ * can only ever show a version that is actually installed.
+ */
+@Composable
+fun CaramelVersionDropdown(
+ versions: List The pack frosts its glass with Haze, a Compose Multiplatform backdrop-blur library that samples
+ * the window behind the card. Android's Compose has no equivalent (a `RenderEffect` can blur a
+ * view's own content, not what is behind it), so this uses the recipe the pack itself falls back to
+ * for ImGui: layered translucent fills that read as glass without a blur pass.
+ *
+ * Two layers, and the light is what sells it: a cool blue-tinted fill at low alpha, then a
+ * vertical sheen that is brightest at the top and falls to nothing, so the card catches light from
+ * above. The hairline is a diagonal gradient rather than a flat colour, bright at the top-left and
+ * dim at the bottom-right, which is the cheap trick that stops a rounded rectangle reading as a
+ * flat outline.
+ */
+@Composable
+fun GlassPanel(
+ modifier: Modifier = Modifier,
+ shape: Shape = RoundedCornerShape(CaramelDimens.RadiusCard),
+ popup: Boolean = false,
+ elevation: Dp = 24.dp,
+ tint: Color = Color(0xFF9DB4FF).copy(alpha = if (popup) 0.10f else 0.06f),
+ content: @Composable BoxScope.() -> Unit,
+) {
+ Box(
+ modifier
+ .shadow(elevation, shape, ambientColor = Color.Black, spotColor = Color(0xFF00010F))
+ .clip(shape)
+ .background(C.Navy)
+ .background(tint)
+ .background(
+ Brush.verticalGradient(
+ listOf(Color.White.copy(0.075f), Color.White.copy(0.015f)),
+ ),
+ )
+ .border(
+ 1.dp,
+ Brush.linearGradient(
+ listOf(Color.White.copy(0.30f), Color.White.copy(0.05f)),
+ ),
+ shape,
+ ),
+ content = content,
+ )
+}
+
+/**
+ * The navy wallpaper with soft colour pools, ported from the pack's `Backdrop`.
+ *
+ * The pools exist so the glass has something to sit on: a flat fill under translucent cards
+ * leaves them looking like slightly lighter rectangles. Four radial gradients, drawn once on a
+ * Canvas, give the glass a light source to refract.
+ */
+@Composable
+fun CaramelBackdrop(modifier: Modifier = Modifier) {
+ Canvas(
+ modifier
+ .fillMaxSize()
+ .background(Brush.verticalGradient(listOf(Color(0xFF070D22), Color(0xFF050918)))),
+ ) {
+ fun pool(x: Float, y: Float, r: Float, c: Color) = drawCircle(
+ Brush.radialGradient(
+ listOf(c, Color.Transparent),
+ Offset(size.width * x, size.height * y),
+ size.width * r,
+ ),
+ radius = size.width * r,
+ center = Offset(size.width * x, size.height * y),
+ )
+ pool(0.18f, 0.20f, 0.42f, Color(0xFF3660FF).copy(0.55f))
+ pool(0.82f, 0.10f, 0.32f, Color(0xFF28AAFF).copy(0.32f))
+ pool(0.62f, 0.92f, 0.40f, Color(0xFF563CFF).copy(0.38f))
+ pool(0.06f, 0.95f, 0.26f, Color(0xFFF5A742).copy(0.20f))
+ }
+}
+
+/** Dimmed scrim plus a centred glass modal with a scale/fade entrance, from the pack's `Modal`. */
+@Composable
+fun CaramelModal(
+ visible: Boolean,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ AnimatedVisibility(visible, enter = fadeIn(), exit = fadeOut()) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(Color(0xAA030714))
+ .clickable(remember { MutableInteractionSource() }, null, onClick = onDismiss),
+ Alignment.Center,
+ ) {
+ AnimatedVisibility(
+ visible,
+ enter = scaleIn(initialScale = 0.96f) + fadeIn(),
+ exit = scaleOut(targetScale = 0.97f) + fadeOut(),
+ ) {
+ GlassPanel(
+ modifier.clickable(remember { MutableInteractionSource() }, null) {},
+ RoundedCornerShape(26.dp),
+ popup = true,
+ elevation = 40.dp,
+ content = content,
+ )
+ }
+ }
+ }
+}
+
+/** The pack's `Pill`: a rounded chip used by the title bar and the account row. */
+@Composable
+fun CaramelPill(
+ modifier: Modifier = Modifier,
+ onClick: (() -> Unit)? = null,
+ content: @Composable RowScope.() -> Unit,
+) {
+ val shape = RoundedCornerShape(50)
+ Row(
+ modifier
+ .height(26.dp)
+ .clip(shape)
+ .background(Color.White.copy(0.07f))
+ .border(1.dp, Color.White.copy(0.16f), shape)
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(horizontal = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(7.dp),
+ content = content,
+ )
+}
diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLabels.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLabels.kt
new file mode 100644
index 00000000..bac7ec80
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelLabels.kt
@@ -0,0 +1,176 @@
+package org.chimeramc.client.ui.compose.caramel
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+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.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Lock
+import androidx.compose.material.icons.rounded.LockOpen
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+/**
+ * The Caramel label and chip set, ported from the pack's `components/Labels.kt`.
+ *
+ * Labels carry status (Release/Beta, Locked/Unlocked, Equipped, a server's version range). The
+ * pack's rule is one tinted pill per status with a matching hairline, so a status reads the same
+ * wherever it appears. Keeping the rule in one function is what stops a "Beta" pill drifting to a
+ * different orange in one screen.
+ */
+@Composable
+fun StatusLabel(
+ text: String,
+ color: Color,
+ modifier: Modifier = Modifier,
+ icon: ImageVector? = null,
+) {
+ val shape = RoundedCornerShape(50)
+ Row(
+ modifier
+ .height(20.dp)
+ .clip(shape)
+ .background(color.copy(0.16f))
+ .border(1.dp, color.copy(0.5f), shape)
+ .padding(horizontal = 9.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ if (icon != null) Icon(icon, null, Modifier.size(11.dp), color)
+ Text(text, style = T.Chip.copy(color = color))
+ }
+}
+
+/** A "Release" (green) or "Beta" (orange) pill. */
+@Composable
+fun ChannelLabel(label: String, color: Color, modifier: Modifier = Modifier) = StatusLabel(label, color, modifier)
+
+/** An "Unlocked" (green, open padlock) or "Locked" (red, closed padlock) pill. */
+@Composable
+fun ProfileLabel(locked: Boolean, modifier: Modifier = Modifier) = StatusLabel(
+ if (locked) "Locked" else "Unlocked",
+ if (locked) C.Red else C.Green,
+ modifier,
+ if (locked) Icons.Rounded.Lock else Icons.Rounded.LockOpen,
+)
+
+/** A selectable filter chip; the selected one is a blue gradient. */
+@Composable
+fun FilterChip(text: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
+ val shape = RoundedCornerShape(50)
+ Box(
+ modifier
+ .height(26.dp)
+ .clip(shape)
+ .background(
+ if (selected) {
+ Brush.verticalGradient(listOf(C.BlueLight, C.BlueDark))
+ } else {
+ SolidColor(Color.White.copy(0.07f))
+ },
+ )
+ .border(1.dp, if (selected) Color.Transparent else Color.White.copy(0.16f), shape)
+ .clickable(remember { MutableInteractionSource() }, null, onClick = onClick)
+ .padding(horizontal = 13.dp),
+ Alignment.Center,
+ ) {
+ Text(text, style = T.Chip.copy(fontSize = 11.5.sp_, color = if (selected) Color.White else C.Text2))
+ }
+}
+
+/** The pack's `SegmentedTabs`: a pill row with a sliding blue selection. */
+@Composable
+fun SegmentedTabs(
+ items: List The pack ships its own `Versions`, `ExampleMods` and `ExampleServers` here. Those are
+ * stand-ins for a demo and are not carried over: the honesty rule forbids drawing a number or a
+ * name the client has not actually measured or fetched, so every list this file describes is
+ * filled by the activity from a real manager. What stays is the shape and the enumerations, so the
+ * screens are a pure function of a small immutable value and can be tested on the JVM.
+ */
+
+/** A version's release channel, which is what the green/orange chip on a version row shows. */
+enum class CaramelChannel(val label: String, val color: Color) {
+ Release("Release", CaramelColors.Green),
+ Beta("Beta", CaramelColors.Orange),
+}
+
+/** One selectable game version. [beta] drives the channel chip. */
+data class CaramelVersion(val id: String, val beta: Boolean) {
+ val channel: CaramelChannel get() = if (beta) CaramelChannel.Beta else CaramelChannel.Release
+}
+
+/** The three loaders the pack's launch menu offers. */
+enum class CaramelLoader(val title: String) {
+ Bedrock("Minecraft Bedrock"),
+ Rust("Rust"),
+ Lightning("Lightning"),
+}
+
+/** The instance profile mode: a shared "Open" profile or an isolated one. */
+enum class CaramelProfileMode(val title: String, val desc: String, val label: String, val locked: Boolean) {
+ Open("Open Instance Profile", "Shares worlds and settings", "Unlocked", false),
+ Isolated("Isolated Instance Profile", "Separate files and mods", "Locked", true),
+}
+
+/** The six launcher tabs the pack's sidebar carries. */
+enum class CaramelTab { Home, Mods, Servers, Cosmetics, News, Store }
+
+/** One mod row: the fields the Mods grid and the instance-settings list draw. */
+data class CaramelModInfo(
+ val name: String,
+ val author: String,
+ val version: String,
+ val description: String,
+ val enabled: Boolean,
+)
+
+/** One saved server: what the Servers tab draws. [online] and [ping] are only set when measured. */
+data class CaramelServerInfo(
+ val name: String,
+ val address: String,
+ val online: Int?,
+ val ping: Int?,
+ val minVersion: String,
+ val maxVersion: String,
+)
+
+/** One cosmetic tile: the Cosmetics grid draws these from the real catalogue. */
+data class CaramelCosmeticInfo(
+ val name: String,
+ val kind: String,
+ val equipped: Boolean,
+ val owned: Boolean,
+ val favourite: Boolean,
+)
+
+/** One news/changelog entry: the News tab draws these from the real changelog. */
+data class CaramelNewsInfo(
+ val title: String,
+ val summary: String,
+ val meta: String,
+ val featured: Boolean,
+)
+
+/**
+ * The launcher's whole mutable state, as the pack's `LauncherState` is.
+ *
+ * Every list defaults to empty and every count to zero: the screens then draw their empty state
+ * rather than a fabricated row. The activity fills them from the live managers.
+ */
+class CaramelLauncherState {
+ var tab by mutableStateOf(CaramelTab.Home)
+
+ var versions by mutableStateOf The pack's `theme/Theme.kt` defines its palette as a flat `object C`. That is kept here rather
+ * than folded into Material's `ColorScheme`, because most of what this UI draws (hairlines, fills,
+ * glass sheens, the caramel accent) has no Material slot. The Material theme is still installed so
+ * `Text`/`Icon` defaults resolve; the tokens below are what the Caramel surfaces actually read.
+ *
+ * Colour values are byte-for-byte the pack's. Changing one here without changing the pack's
+ * `Theme.kt` and `CaramelTheme.cpp` (the ImGui twin) is how the launcher and the in-game menu drift
+ * apart, so the three are meant to move together.
+ */
+object CaramelColors {
+ val Abyss = Color(0xFF060B1A)
+ val Navy = Color(0xFF0A1330)
+ val DeepBlue = Color(0xFF0F1B44)
+ val Blue = Color(0xFF4A78FF)
+ val BlueLight = Color(0xFF628CFF)
+ val BlueDark = Color(0xFF3458E6)
+ val Caramel = Color(0xFFF5A742)
+ val CaramelDark = Color(0xFFDE8624)
+ val Green = Color(0xFF2FD36B)
+ val GreenLight = Color(0xFF3EE27A)
+ val GreenDark = Color(0xFF18A050)
+ val Red = Color(0xFFFF5468)
+ val Orange = Color(0xFFFF9F2E)
+ val Yellow = Color(0xFFFFD040)
+ val Text = Color(0xFFEAF0FF)
+ val Text2 = Color(0xFF9AA8D0)
+ val Text3 = Color(0xFF66749E)
+ val OnGreen = Color(0xFF062412)
+ val Hairline = Color.White.copy(alpha = 0.14f)
+ val Fill = Color.White.copy(alpha = 0.07f)
+ val FillStrong = Color.White.copy(alpha = 0.12f)
+}
+
+/** Short alias, matching the pack's `C`. */
+typealias C = CaramelColors
+
+/**
+ * The pack's type scale: Poppins for display and titles, Inter for controls and body.
+ *
+ * Both families ship as variable fonts in `res/font`, so one file per family carries every
+ * weight and the weight is selected by axis rather than by a second file.
+ */
+private val Poppins = FontFamily(
+ Font(R.font.poppins, FontWeight.Normal),
+ Font(R.font.poppins, FontWeight.Medium),
+ Font(R.font.poppins, FontWeight.Bold),
+)
+
+private val Inter = FontFamily(
+ Font(R.font.inter, FontWeight.Normal),
+ Font(R.font.inter, FontWeight.Medium),
+ Font(R.font.inter, FontWeight.SemiBold),
+ Font(R.font.inter, FontWeight.Bold),
+)
+
+/** Short alias, matching the pack's `T`. */
+object CaramelType {
+ val Display = TextStyle(fontFamily = Poppins, fontWeight = FontWeight.Bold, fontSize = 24.sp, color = C.Text)
+ val Title = TextStyle(fontFamily = Poppins, fontWeight = FontWeight.Medium, fontSize = 16.sp, color = C.Text)
+ val Subtitle = TextStyle(fontFamily = Poppins, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = C.Text)
+ val Button = TextStyle(fontFamily = Poppins, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = C.Text)
+ val Label = TextStyle(fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = C.Text)
+ val Body = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, color = C.Text2)
+ val Caption = TextStyle(fontFamily = Inter, fontWeight = FontWeight.Normal, fontSize = 11.sp, color = C.Text3)
+ val Chip = TextStyle(fontFamily = Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.5.sp)
+}
+
+typealias T = CaramelType
+
+/**
+ * Installs the Caramel palette into Material so `Text`, `Icon` and the Material defaults resolve to
+ * Caramel colours.
+ *
+ * Most Caramel surfaces read [CaramelColors] directly — they draw things Material's scheme has no
+ * slot for. This wrapper exists so a Material component dropped into the tree (a
+ * `CircularProgressIndicator`, a ripple) does not fall back to the default purple.
+ */
+@Composable
+fun CaramelTheme(content: @Composable () -> Unit) {
+ androidx.compose.material3.MaterialTheme(
+ colorScheme = androidx.compose.material3.darkColorScheme(
+ primary = C.Blue,
+ onPrimary = Color.White,
+ primaryContainer = C.DeepBlue,
+ onPrimaryContainer = C.Text,
+ secondary = C.Caramel,
+ onSecondary = Color.White,
+ background = C.Abyss,
+ onBackground = C.Text,
+ surface = C.Navy,
+ onSurface = C.Text,
+ surfaceVariant = C.DeepBlue,
+ onSurfaceVariant = C.Text2,
+ outline = C.Hairline,
+ error = C.Red,
+ onError = Color.White,
+ ),
+ content = content,
+ )
+}
+
+/** The pack's spacing and radius scale. */
+object CaramelDimens {
+ val RadiusCard = 20.dp
+ val RadiusHero = 24.dp
+ val RadiusPanel = 18.dp
+ val RadiusControl = 11.dp
+ val RadiusButton = 12.dp
+ val PagePad = 14.dp
+ val Gap = 16.dp
+ val GapLarge = 20.dp
+}
diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelUtil.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelUtil.kt
new file mode 100644
index 00000000..35956d01
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/CaramelUtil.kt
@@ -0,0 +1,7 @@
+package org.chimeramc.client.ui.compose.caramel
+
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+
+/** The pack's `onFocusChangedCompat`, used by [CaramelTextField]. */
+fun Modifier.onFocusChangedCompat(block: (Boolean) -> Unit): Modifier = onFocusChanged { block(it.isFocused) }
diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelCosmeticsStore.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelCosmeticsStore.kt
new file mode 100644
index 00000000..046479f3
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelCosmeticsStore.kt
@@ -0,0 +1,231 @@
+package org.chimeramc.client.ui.compose.caramel.launcher
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Check
+import androidx.compose.material.icons.rounded.Star
+import androidx.compose.material.icons.rounded.StarBorder
+import androidx.compose.material.icons.rounded.SwapHoriz
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.chimeramc.client.ui.compose.caramel.C
+import org.chimeramc.client.ui.compose.caramel.CaramelCosmeticInfo
+import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState
+import org.chimeramc.client.ui.compose.caramel.FilterChip
+import org.chimeramc.client.ui.compose.caramel.GlassButton
+import org.chimeramc.client.ui.compose.caramel.GlassPanel
+import org.chimeramc.client.ui.compose.caramel.PrimaryButton
+import org.chimeramc.client.ui.compose.caramel.StatusLabel
+import org.chimeramc.client.ui.compose.caramel.T
+import org.chimeramc.client.ui.compose.caramel.sp_
+
+private val cosAccents = listOf(C.Blue, C.Caramel, C.Green, Color(0xFF966EFF))
+
+/**
+ * The Cosmetics screen, ported from the pack's `CosmeticsScreen`.
+ *
+ * The pack reserves a white rounded square for the 3D skin viewer; on Android the client already
+ * has a software skin renderer, so the slot is filled by the host through [skinViewer]. When the
+ * host passes nothing, the slot shows its empty label rather than a fake model.
+ */
+@Composable
+fun CaramelCosmeticsScreen(
+ state: CaramelLauncherState,
+ onToggleFavourite: (CaramelCosmeticInfo) -> Unit,
+ onEquip: (CaramelCosmeticInfo) -> Unit,
+ onResetView: () -> Unit,
+ skinViewer: @Composable (Modifier) -> Unit = { EmptySkinSlot(it) },
+) {
+ var chip by remember { mutableStateOf(0) }
+ CaramelPage("Cosmetics", "Capes, wings, hats and more") {
+ Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
+ GlassPanel(Modifier.width(320.dp).fillMaxHeight(), RoundedCornerShape(24.dp)) {
+ Column(
+ Modifier.fillMaxSize().padding(28.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Box(
+ Modifier.fillMaxWidth().weight(1f).background(Color.White, RoundedCornerShape(22.dp)),
+ Alignment.Center,
+ ) { skinViewer(Modifier.fillMaxSize().padding(8.dp)) }
+ GlassButton("Save outfit", {}, Modifier.fillMaxWidth(), icon = Icons.Rounded.Check)
+ GlassButton("Reset view", onResetView, Modifier.fillMaxWidth(), icon = Icons.Rounded.SwapHoriz)
+ }
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ listOf("All", "Capes", "Wings", "Hats", "Pets").forEachIndexed { i, t -> FilterChip(t, chip == i, { chip = i }) }
+ FilterChip("Favorites", chip == 5, { chip = 5 })
+ }
+ if (state.cosmetics.isEmpty()) {
+ EmptyState("No cosmetics yet", "Capes and hats you own appear here.", Modifier.fillMaxWidth().weight(1f))
+ } else {
+ LazyVerticalGrid(
+ GridCells.Adaptive(190.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(state.cosmetics, key = { it.name }) { c ->
+ CosmeticTile(c, { onToggleFavourite(c) }, { onEquip(c) })
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun EmptySkinSlot(modifier: Modifier) {
+ Box(modifier, Alignment.Center) {
+ Text(
+ "3D skin preview",
+ style = T.Title.copy(color = Color(0xFF1C2854), fontSize = 15.sp_, textAlign = TextAlign.Center),
+ modifier = Modifier.width(190.dp),
+ )
+ }
+}
+
+@Composable
+private fun CosmeticTile(c: CaramelCosmeticInfo, onFav: () -> Unit, onEquip: () -> Unit) {
+ val accent = cosAccents[(c.name.hashCode() and 0x7fffffff) % 4]
+ GlassPanel(Modifier.fillMaxWidth().height(188.dp), RoundedCornerShape(18.dp), elevation = 6.dp) {
+ Column(Modifier.padding(8.dp)) {
+ Box(
+ Modifier.fillMaxWidth().height(112.dp)
+ .background(Brush.verticalGradient(listOf(accent.copy(0.32f), accent.copy(0.05f))), RoundedCornerShape(12.dp)),
+ Alignment.Center,
+ ) {
+ Box(Modifier.size(40.dp, 68.dp).background(accent, RoundedCornerShape(4.dp)))
+ Box(
+ Modifier.align(Alignment.TopEnd).padding(8.dp).size(28.dp).clip(CircleShape)
+ .background(Color(0x96081024))
+ .border(1.dp, if (c.favourite) C.Caramel else Color.White.copy(0.25f), CircleShape)
+ .clickable(remember { MutableInteractionSource() }, null, onClick = onFav),
+ Alignment.Center,
+ ) {
+ Icon(
+ if (c.favourite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
+ null, Modifier.size(16.dp), if (c.favourite) C.Caramel else C.Text,
+ )
+ }
+ }
+ Column(Modifier.padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(c.name, style = T.Label, maxLines = 1)
+ if (c.equipped) {
+ StatusLabel("Equipped", C.Green)
+ } else if (c.owned) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Owned", style = T.Body, modifier = Modifier.weight(1f))
+ GlassButton("Equip", onEquip, height = 28)
+ }
+ } else {
+ StatusLabel("Locked", C.Caramel)
+ }
+ }
+ }
+ }
+}
+
+/** The News screen: a featured post over a side list. */
+@Composable
+fun CaramelNewsScreen(state: CaramelLauncherState) {
+ CaramelPage("News", "Updates, events and community posts") {
+ Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
+ val featured = state.news.firstOrNull { it.featured } ?: state.news.firstOrNull()
+ if (featured == null) {
+ EmptyState("No news yet", "Posts appear here once the changelog loads.", Modifier.fillMaxSize())
+ } else {
+ GlassPanel(Modifier.weight(1.3f).fillMaxHeight(), RoundedCornerShape(24.dp)) {
+ Column(Modifier.padding(6.dp)) {
+ Box(
+ Modifier.fillMaxWidth().height(260.dp)
+ .background(Brush.verticalGradient(listOf(Color(0xFF5A2818), Color(0xFFF5963C))), RoundedCornerShape(18.dp)),
+ )
+ Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ StatusLabel("Featured", C.Caramel)
+ Text(featured.title, style = T.Display)
+ Text(featured.summary, style = T.Body.copy(fontSize = 13.sp_))
+ Text(featured.meta, style = T.Label.copy(color = C.Caramel))
+ }
+ }
+ }
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ state.news.drop(1).take(3).forEachIndexed { i, n ->
+ GlassPanel(Modifier.fillMaxWidth().height(130.dp), RoundedCornerShape(20.dp), elevation = 6.dp) {
+ Row(Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) {
+ Box(
+ Modifier.size(150.dp, 106.dp)
+ .background(Brush.verticalGradient(listOf(cosAccents[i % 4].copy(0.5f), cosAccents[i % 4].copy(0.1f))), RoundedCornerShape(12.dp)),
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(n.title, style = T.Subtitle.copy(fontSize = 15.sp_), maxLines = 2)
+ Text(n.summary, style = T.Body, maxLines = 2)
+ Text(n.meta, style = T.Caption)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** The Store screen: a Caramel+ banner over the bundle grid. */
+@Composable
+fun CaramelStoreScreen(state: CaramelLauncherState) {
+ CaramelPage("Store", "Cosmetics, bundles and Caramel+") {
+ GlassPanel(Modifier.fillMaxWidth().height(160.dp), RoundedCornerShape(24.dp)) {
+ Box(Modifier.matchParentSize().background(Brush.horizontalGradient(listOf(C.Caramel.copy(0.3f), C.Blue.copy(0.08f)))))
+ Row(Modifier.fillMaxSize().padding(horizontal = 36.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text("Caramel+", style = T.Display.copy(fontSize = 28.sp_))
+ Text(
+ "Support the client and unlock cosmetics, themes and early builds.",
+ style = T.Body.copy(fontSize = 13.5.sp_),
+ )
+ }
+ PrimaryButton("Learn more", {}, Modifier.width(200.dp))
+ }
+ }
+ EmptyState(
+ "Store coming soon",
+ "Bundles will appear here once the store backend is live.",
+ Modifier.fillMaxWidth().weight(1f),
+ )
+ }
+}
diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelHome.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelHome.kt
new file mode 100644
index 00000000..75836374
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelHome.kt
@@ -0,0 +1,276 @@
+package org.chimeramc.client.ui.compose.caramel.launcher
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Add
+import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.Group
+import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material.icons.rounded.KeyboardArrowUp
+import androidx.compose.material.icons.rounded.PhotoCamera
+import androidx.compose.material.icons.rounded.RocketLaunch
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.Tune
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.chimeramc.client.R
+import org.chimeramc.client.ui.compose.caramel.C
+import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState
+import org.chimeramc.client.ui.compose.caramel.GlassButton
+import org.chimeramc.client.ui.compose.caramel.GlassPanel
+import org.chimeramc.client.ui.compose.caramel.PrimaryButton
+import org.chimeramc.client.ui.compose.caramel.T
+import org.chimeramc.client.ui.compose.caramel.Tone
+import org.chimeramc.client.ui.compose.caramel.sp_
+
+/**
+ * The Home screen, ported from the pack's `launcher/Home.kt`.
+ *
+ * The pack's hero, launch split button, latest-news row, friends card and Caramel+ promo are all
+ * here. The two cards whose content is data (news, the version line) read [CaramelLauncherState]
+ * rather than the pack's literals, so nothing drawn is a value the client did not measure.
+ */
+@Composable
+fun CaramelHomeScreen(
+ state: CaramelLauncherState,
+ onLaunch: () -> Unit,
+ onOpenContent: () -> Unit,
+) {
+ Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
+ Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(20.dp)) {
+ Hero(state, onLaunch, onOpenContent, Modifier.fillMaxWidth().height(320.dp))
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text("Latest news", style = T.Title, modifier = Modifier.weight(1f))
+ Text("View all news", style = T.Label.copy(color = C.Text2, fontSize = 11.5.sp_))
+ }
+ Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ val cards = state.news.take(3)
+ if (cards.isEmpty()) {
+ EmptyCard("No news yet", "New posts appear here once the changelog loads.", Modifier.weight(1f))
+ } else {
+ cards.forEachIndexed { i, n ->
+ NewsCard(n.title, n.meta, newsArt(i), Modifier.weight(1f))
+ }
+ }
+ }
+ }
+ Column(Modifier.width(374.dp).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(20.dp)) {
+ FriendsCard(state, Modifier.fillMaxWidth().height(320.dp))
+ PromoCard(Modifier.fillMaxWidth().weight(1f))
+ }
+ }
+}
+
+/** The three news-card art gradients, matching the pack's amber / blue / pink rotation. */
+private fun newsArt(i: Int): List A `@Composable` function cannot be called from Java: the compiler threads a `Composer`
+ * parameter and a content lambda whose type Java cannot name. This host is the seam — the activity
+ * builds the state and hands over plain callbacks, and everything Compose-shaped stays on the
+ * Kotlin side. It mirrors the pack's `Main.kt` entry, minus the desktop `Window`.
+ */
+object CaramelLauncherHost {
+
+ /** The whole launcher state, as a Kotlin [MutableState] so a set recomposes. */
+ @JvmStatic
+ fun state(): MutableState The skin preview slot is left to [skinViewerProvider]: a `@Composable` lambda cannot be
+ * passed from Java, so the activity opts in by setting the provider from Kotlin code (or not at
+ * all, in which case the Cosmetics screen shows its empty slot).
+ */
+ @JvmStatic
+ fun setContent(
+ composeView: ComposeView,
+ state: MutableState The frame (backdrop, title bar, sidebar) hosts the active tab, and the overlays and modals sit
+ * above it. This is the single composable the activity mounts through [CaramelLauncherHost].
+ */
+@Composable
+fun CaramelLauncherScreen(
+ state: CaramelLauncherState,
+ actions: CaramelLauncherHost.Actions,
+ skinViewer: @Composable (Modifier) -> Unit,
+) {
+ Box(Modifier.fillMaxSize()) {
+ CaramelLauncherFrame(state) { contentModifier ->
+ Box(contentModifier) {
+ when (state.tab) {
+ CaramelTab.Home -> CaramelHomeScreen(state, actions::launch, actions::openContent)
+ CaramelTab.Mods -> CaramelModsScreen(
+ state,
+ onToggle = actions::toggleMod,
+ onOpenFolder = actions::openModsFolder,
+ )
+ CaramelTab.Servers -> CaramelServersScreen(state, actions::joinServer)
+ CaramelTab.Cosmetics -> CaramelCosmeticsScreen(
+ state,
+ onToggleFavourite = actions::toggleFavourite,
+ onEquip = actions::equipCosmetic,
+ onResetView = {},
+ skinViewer = skinViewer,
+ )
+ CaramelTab.News -> CaramelNewsScreen(state)
+ CaramelTab.Store -> CaramelStoreScreen(state)
+ }
+ }
+ }
+ CaramelLaunchMenuOverlay(state, actions::launch)
+ CaramelModDetailModal(state, actions::toggleMod)
+ CaramelAppMenuOverlay(state, actions::quit)
+ }
+}
diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelModsServers.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelModsServers.kt
new file mode 100644
index 00000000..a9d2d89d
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelModsServers.kt
@@ -0,0 +1,227 @@
+package org.chimeramc.client.ui.compose.caramel.launcher
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+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.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Bolt
+import androidx.compose.material.icons.rounded.Dashboard
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.Place
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.Wifi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+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.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.chimeramc.client.ui.compose.caramel.C
+import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState
+import org.chimeramc.client.ui.compose.caramel.CaramelModInfo
+import org.chimeramc.client.ui.compose.caramel.CaramelServerInfo
+import org.chimeramc.client.ui.compose.caramel.CaramelToggle
+import org.chimeramc.client.ui.compose.caramel.FilterChip
+import org.chimeramc.client.ui.compose.caramel.GlassButton
+import org.chimeramc.client.ui.compose.caramel.GlassPanel
+import org.chimeramc.client.ui.compose.caramel.PrimaryButton
+import org.chimeramc.client.ui.compose.caramel.SearchField
+import org.chimeramc.client.ui.compose.caramel.ServerIcon
+import org.chimeramc.client.ui.compose.caramel.StatusLabel
+import org.chimeramc.client.ui.compose.caramel.T
+import org.chimeramc.client.ui.compose.caramel.VersionRangeLabel
+import org.chimeramc.client.ui.compose.caramel.sp_
+
+private val accents = listOf(C.Blue, C.Caramel, C.Green, Color(0xFF966EFF))
+
+/** The pack's `Page` header: a display title, a subtitle and optional trailing actions. */
+@Composable
+internal fun CaramelPage(
+ title: String,
+ sub: String,
+ actions: @Composable () -> Unit = {},
+ body: @Composable ColumnScope.() -> Unit,
+) {
+ Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Column(Modifier.weight(1f)) {
+ Text(title, style = T.Display)
+ Text(sub, style = T.Body.copy(fontSize = 12.5.sp_))
+ }
+ actions()
+ }
+ body()
+ }
+}
+
+@Composable
+private fun IconTile(icon: ImageVector, color: Color, size: Int = 48) =
+ Box(
+ Modifier.size(size.dp).background(color.copy(0.2f), RoundedCornerShape(14.dp))
+ .border(1.dp, color.copy(0.55f), RoundedCornerShape(14.dp)),
+ Alignment.Center,
+ ) { Icon(icon, null, Modifier.size((size / 2).dp), color) }
+
+/** The Mods screen: search, category chips and a card grid. Tapping a card opens the detail modal. */
+@Composable
+fun CaramelModsScreen(
+ state: CaramelLauncherState,
+ onToggle: (CaramelModInfo, Boolean) -> Unit,
+ onOpenFolder: () -> Unit,
+) {
+ var q by remember { mutableStateOf("") }
+ var chip by remember { mutableStateOf(0) }
+ CaramelPage(
+ "Mods",
+ "Browse, install and toggle mods for this instance",
+ {
+ SearchField(q, { q = it }, "Search mods", Modifier.width(300.dp))
+ Spacer(Modifier.width(10.dp))
+ GlassButton("Open mods folder", onOpenFolder, icon = Icons.Rounded.Folder)
+ },
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf("All", "Installed", "Performance", "Visual", "Utility", "Fun").forEachIndexed { i, t ->
+ FilterChip(t, chip == i, { chip = i })
+ }
+ }
+ val list = remember(q, state.mods) {
+ state.mods.filter { q.trim().isEmpty() || it.name.contains(q.trim(), ignoreCase = true) }
+ }
+ if (list.isEmpty()) {
+ EmptyState("No mods installed", "Mods you add to this instance appear here.", Modifier.fillMaxSize())
+ } else {
+ LazyVerticalGrid(
+ GridCells.Adaptive(270.dp),
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ ) {
+ items(list, key = { it.name }) { m ->
+ GlassPanel(
+ Modifier.fillMaxWidth().height(218.dp)
+ .clickable(remember { MutableInteractionSource() }, null) { state.selectedMod = m },
+ RoundedCornerShape(20.dp),
+ elevation = 8.dp,
+ ) {
+ Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.CenterVertically) {
+ IconTile(modIcon(m), accents[modIconIndex(m)], 48)
+ Column {
+ Text(m.name, style = T.Subtitle)
+ Text("by ${m.author}", style = T.Body)
+ }
+ }
+ Text(m.description, style = T.Body, maxLines = 3)
+ StatusLabel(if (m.enabled) "Enabled" else "Disabled", if (m.enabled) C.Green else C.Text2)
+ Spacer(Modifier.weight(1f))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("v${m.version}", style = T.Caption, modifier = Modifier.weight(1f))
+ CaramelToggle(m.enabled, { onToggle(m, it) })
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun modIcon(m: CaramelModInfo): ImageVector = listOf(
+ Icons.Rounded.Extension, Icons.Rounded.Bolt, Icons.Rounded.Place, Icons.Rounded.Dashboard,
+)[modIconIndex(m)]
+
+private fun modIconIndex(m: CaramelModInfo): Int = (m.name.hashCode() and 0x7fffffff) % 4
+
+/** The Servers screen: a featured banner over the saved-server rows. */
+@Composable
+fun CaramelServersScreen(state: CaramelLauncherState, onJoin: (CaramelServerInfo) -> Unit) {
+ CaramelPage("Servers", "Featured and saved servers") {
+ val featured = state.servers.firstOrNull()
+ if (featured != null) {
+ GlassPanel(Modifier.fillMaxWidth().height(180.dp), RoundedCornerShape(24.dp)) {
+ Box(Modifier.matchParentSize().background(Brush.horizontalGradient(listOf(C.Blue.copy(0.28f), C.Caramel.copy(0.16f)))))
+ Row(Modifier.fillMaxSize().padding(36.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(featured.name, style = T.Display.copy(fontSize = 26.sp_))
+ Text(featured.address, style = T.Body.copy(fontSize = 13.sp_))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ featured.online?.let { StatusLabel("$it playing", C.Green) }
+ VersionRangeLabel(featured.minVersion, featured.maxVersion)
+ }
+ }
+ PrimaryButton("Join server", { onJoin(featured) }, Modifier.width(176.dp), icon = Icons.Rounded.PlayArrow)
+ }
+ }
+ }
+ if (state.servers.isEmpty()) {
+ EmptyState("No servers saved", "Add a server and it appears here.", Modifier.fillMaxWidth().weight(1f))
+ } else {
+ Column(Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ state.servers.forEachIndexed { i, s ->
+ GlassPanel(Modifier.fillMaxWidth().height(80.dp), RoundedCornerShape(20.dp), elevation = 6.dp) {
+ Row(
+ Modifier.fillMaxSize().padding(horizontal = 22.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(18.dp),
+ ) {
+ ServerIcon(accents[i % 4], 52)
+ Column(Modifier.weight(1f)) {
+ Text(s.name, style = T.Subtitle.copy(fontSize = 15.sp_))
+ Text(s.address, style = T.Body)
+ }
+ s.online?.let { Text("$it online", style = T.Label) }
+ if (s.online != null) Icon(Icons.Rounded.Wifi, null, Modifier.size(18.dp), C.Green)
+ s.ping?.let { Text("$it ms", style = T.Body) }
+ VersionRangeLabel(s.minVersion, s.maxVersion)
+ GlassButton("Join", { onJoin(s) }, icon = Icons.Rounded.PlayArrow, height = 36)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** The shared empty state, so a screen never draws a fabricated row in place of real data. */
+@Composable
+internal fun EmptyState(title: String, body: String, modifier: Modifier = Modifier) {
+ GlassPanel(modifier, RoundedCornerShape(20.dp), elevation = 6.dp) {
+ Column(
+ Modifier.fillMaxSize().padding(28.dp),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(title, style = T.Subtitle)
+ Spacer(Modifier.height(6.dp))
+ Text(body, style = T.Body, textAlign = TextAlign.Center)
+ }
+ }
+}
diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelOverlays.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelOverlays.kt
new file mode 100644
index 00000000..fcb89a24
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelOverlays.kt
@@ -0,0 +1,156 @@
+package org.chimeramc.client.ui.compose.caramel.launcher
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.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.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Close
+import androidx.compose.material.icons.rounded.RocketLaunch
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupProperties
+import org.chimeramc.client.ui.compose.caramel.C
+import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState
+import org.chimeramc.client.ui.compose.caramel.CaramelLoader
+import org.chimeramc.client.ui.compose.caramel.CaramelModInfo
+import org.chimeramc.client.ui.compose.caramel.CaramelModal
+import org.chimeramc.client.ui.compose.caramel.CaramelProfileDropdown
+import org.chimeramc.client.ui.compose.caramel.CaramelVersionDropdown
+import org.chimeramc.client.ui.compose.caramel.GlassButton
+import org.chimeramc.client.ui.compose.caramel.GlassPanel
+import org.chimeramc.client.ui.compose.caramel.LoaderTile
+import org.chimeramc.client.ui.compose.caramel.PrimaryButton
+import org.chimeramc.client.ui.compose.caramel.T
+import org.chimeramc.client.ui.compose.caramel.Tone
+import org.chimeramc.client.ui.compose.caramel.sp_
+
+/**
+ * The pack's launch menu, its mod-detail modal and the Caramel Client app menu.
+ *
+ * The pack anchors the launch menu to a fixed desktop offset. On a phone the surface is a
+ * different shape, so the menu is centred as a popup; its contents (version, loaders, profile
+ * mode, launch) are the pack's.
+ */
+@Composable
+fun CaramelLaunchMenuOverlay(state: CaramelLauncherState, onLaunch: () -> Unit) {
+ if (!state.launchMenu) return
+ Popup(
+ alignment = Alignment.Center,
+ onDismissRequest = { state.launchMenu = false },
+ properties = PopupProperties(focusable = true),
+ ) {
+ GlassPanel(Modifier.width(372.dp), RoundedCornerShape(22.dp), popup = true, elevation = 40.dp) {
+ Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Version", style = T.Title.copy(fontSize = 15.sp_), modifier = Modifier.weight(1f))
+ CaramelVersionDropdown(state.versions, state.version, onSelect = { state.version = it })
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Loaders", style = T.Title.copy(fontSize = 15.sp_), modifier = Modifier.weight(1f))
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ CaramelLoader.entries.forEach { LoaderTile(it, state.loader == it, { state.loader = it }) }
+ }
+ }
+ CaramelProfileDropdown(state.profile, { state.profile = it }, width = 328)
+ Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
+ GlassButton(null, { state.launchMenu = false; state.instanceSettings = true }, icon = Icons.Rounded.Settings, height = 44)
+ PrimaryButton("Launch Game", { state.launchMenu = false; onLaunch() }, Modifier.weight(1f), icon = Icons.Rounded.RocketLaunch)
+ }
+ }
+ }
+ }
+}
+
+/** The mod detail modal, opened by tapping a Mods card. */
+@Composable
+fun CaramelModDetailModal(state: CaramelLauncherState, onToggle: (CaramelModInfo, Boolean) -> Unit) {
+ val mod = state.selectedMod
+ CaramelModal(mod != null, { state.selectedMod = null }, Modifier.width(520.dp)) {
+ if (mod == null) return@CaramelModal
+ Column(Modifier.fillMaxSize().padding(28.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(mod.name, style = T.Display.copy(fontSize = 20.sp_, fontWeight = FontWeight.Medium))
+ Text("by ${mod.author} · v${mod.version}", style = T.Body)
+ }
+ GlassButton(null, { state.selectedMod = null }, icon = Icons.Rounded.Close, height = 32)
+ }
+ Text(mod.description, style = T.Body.copy(fontSize = 13.sp_))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(if (mod.enabled) "Enabled" else "Disabled", style = T.Label, modifier = Modifier.weight(1f))
+ CaramelToggleRef(mod.enabled) { onToggle(mod, it) }
+ }
+ Spacer(Modifier.height(2.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ GlassButton("Open config", {}, Modifier.weight(1f), height = 40)
+ PrimaryButton(if (mod.enabled) "Disable" else "Enable", { onToggle(mod, !mod.enabled) }, Modifier.weight(1f), height = 40)
+ }
+ }
+ }
+}
+
+/** The Caramel Client app menu: the dropdown by the title bar's arrow, which carries Quit. */
+@Composable
+fun CaramelAppMenuOverlay(state: CaramelLauncherState, onQuit: () -> Unit) {
+ if (!state.appMenu) return
+ Popup(
+ alignment = Alignment.TopStart,
+ offset = IntOffset(70, 40),
+ onDismissRequest = { state.appMenu = false },
+ properties = PopupProperties(focusable = true),
+ ) {
+ GlassPanel(Modifier.width(230.dp), RoundedCornerShape(18.dp), popup = true, elevation = 36.dp) {
+ Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ CaramelAppMenuRow("Settings", false) { state.appMenu = false; state.launcherSettings = true }
+ CaramelAppMenuRow("Instance settings", false) { state.appMenu = false; state.instanceSettings = true }
+ CaramelAppMenuRow("About", false) { state.appMenu = false }
+ Spacer(Modifier.height(4.dp))
+ CaramelAppMenuRow("Quit", true) { state.appMenu = false; onQuit() }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CaramelAppMenuRow(label: String, danger: Boolean, onClick: () -> Unit) {
+ Row(
+ Modifier.fillMaxWidth().height(36.dp).clip(RoundedCornerShape(10.dp))
+ .clickable(remember { MutableInteractionSource() }, null, onClick = onClick)
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ label,
+ style = T.Label.copy(color = if (danger) C.Red else C.Text, fontWeight = FontWeight.Medium),
+ )
+ }
+}
+
+/** A local alias so this file does not import the control just for one call. */
+@Composable
+private fun CaramelToggleRef(checked: Boolean, onChange: (Boolean) -> Unit) =
+ org.chimeramc.client.ui.compose.caramel.CaramelToggle(checked, onChange)
diff --git a/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelShell.kt b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelShell.kt
new file mode 100644
index 00000000..547b9c6a
--- /dev/null
+++ b/app/src/main/java/org/chimeramc/client/ui/compose/caramel/launcher/CaramelShell.kt
@@ -0,0 +1,174 @@
+package org.chimeramc.client.ui.compose.caramel.launcher
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.AccountCircle
+import androidx.compose.material.icons.rounded.Article
+import androidx.compose.material.icons.rounded.Extension
+import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material.icons.rounded.KeyboardArrowUp
+import androidx.compose.material.icons.rounded.Public
+import androidx.compose.material.icons.rounded.RocketLaunch
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.ShoppingCart
+import androidx.compose.material.icons.rounded.SportsEsports
+import androidx.compose.material.icons.rounded.ViewInAr
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import org.chimeramc.client.R
+import org.chimeramc.client.ui.compose.caramel.C
+import org.chimeramc.client.ui.compose.caramel.CaramelBackdrop
+import org.chimeramc.client.ui.compose.caramel.CaramelLauncherState
+import org.chimeramc.client.ui.compose.caramel.CaramelPill
+import org.chimeramc.client.ui.compose.caramel.CaramelTab
+import org.chimeramc.client.ui.compose.caramel.GlassPanel
+import org.chimeramc.client.ui.compose.caramel.ServerIcon
+import org.chimeramc.client.ui.compose.caramel.T
+import org.chimeramc.client.ui.compose.caramel.sp_
+
+/**
+ * The launcher frame, ported from the pack's `launcher/Shell.kt`.
+ *
+ * The pack mounts its frame inside a desktop `Window` with a `WindowDraggableArea`. On Android
+ * the window is the activity, so the drag area is dropped and the title bar is a plain row. The
+ * backdrop, the glass title bar and the 56dp sidebar rail are the pack's.
+ */
+@Composable
+fun CaramelLauncherFrame(
+ state: CaramelLauncherState,
+ content: @Composable (Modifier) -> Unit,
+) {
+ Box(Modifier.fillMaxSize().background(C.Abyss)) {
+ CaramelBackdrop()
+ Column(Modifier.fillMaxSize()) {
+ CaramelTitleBar(state)
+ Row(
+ Modifier.fillMaxSize().padding(start = 14.dp, end = 14.dp, bottom = 14.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ CaramelSidebar(state)
+ Box(Modifier.weight(1f).fillMaxHeight()) { content(Modifier.fillMaxSize()) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CaramelTitleBar(state: CaramelLauncherState) {
+ Row(
+ Modifier.fillMaxWidth().height(44.dp).padding(horizontal = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Image(painterResource(R.drawable.ic_caramel_logo), null, Modifier.size(30.dp))
+ Text("Caramel Client", style = T.Subtitle)
+ val menuShape = RoundedCornerShape(8.dp)
+ Box(
+ Modifier.size(22.dp).clip(menuShape)
+ .background(Color.White.copy(if (state.appMenu) 0.14f else 0.06f))
+ .border(1.dp, Color.White.copy(0.18f), menuShape)
+ .clickable(remember { MutableInteractionSource() }, null) { state.appMenu = !state.appMenu },
+ Alignment.Center,
+ ) {
+ Icon(
+ if (state.appMenu) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown,
+ null, Modifier.size(15.dp), C.Text2,
+ )
+ }
+ CaramelPill {
+ Icon(Icons.Rounded.RocketLaunch, null, Modifier.size(13.dp), C.Text2)
+ Text("Launchpad", style = T.Label.copy(fontSize = 11.5.sp_, color = C.Text2))
+ }
+ Spacer(Modifier.width(12.dp))
+ Text("Quick play", style = T.Caption)
+ // Quick play tiles: one per saved server, capped so the row never overflows.
+ state.servers.take(8).forEachIndexed { i, _ -> ServerIcon(quickPlayAccent(i), 26) }
+ Spacer(Modifier.weight(1f))
+ Text("${state.runningInstances} instances", style = T.Caption)
+ CaramelPill(onClick = { state.accountMenu = !state.accountMenu }) {
+ if (state.signedIn) {
+ Icon(Icons.Rounded.AccountCircle, null, Modifier.size(18.dp), C.Text2)
+ Text(state.accountName, style = T.Label.copy(fontSize = 11.5.sp_))
+ } else {
+ Text("Sign in", style = T.Label.copy(fontSize = 11.5.sp_))
+ }
+ Icon(Icons.Rounded.KeyboardArrowDown, null, Modifier.size(14.dp), C.Text3)
+ }
+ }
+}
+
+/** The eight quick-play accents, in order, so a tile keeps its colour as servers are added. */
+private fun quickPlayAccent(i: Int): Color = listOf(
+ C.Caramel, C.Blue, C.Green, C.Red,
+ Color(0xFF966EFF), Color(0xFF4AC8E6), Color(0xFFFF8C3C), Color(0xFF7882FF),
+)[i % 8]
+
+private val nav = listOf(
+ CaramelTab.Home to Icons.Rounded.SportsEsports,
+ CaramelTab.Mods to Icons.Rounded.Extension,
+ CaramelTab.Servers to Icons.Rounded.Public,
+ CaramelTab.Cosmetics to Icons.Rounded.ViewInAr,
+ CaramelTab.News to Icons.Rounded.Article,
+ CaramelTab.Store to Icons.Rounded.ShoppingCart,
+)
+
+@Composable
+private fun CaramelSidebar(state: CaramelLauncherState) {
+ GlassPanel(Modifier.width(56.dp).fillMaxHeight(), RoundedCornerShape(18.dp), elevation = 12.dp) {
+ Column(
+ Modifier.fillMaxSize().padding(vertical = 14.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ nav.forEach { (tab, icon) -> CaramelNavItem(icon, state.tab == tab) { state.tab = tab } }
+ Spacer(Modifier.weight(1f))
+ CaramelNavItem(Icons.Rounded.Settings, state.launcherSettings) { state.launcherSettings = true }
+ }
+ }
+}
+
+@Composable
+private fun CaramelNavItem(icon: ImageVector, active: Boolean, onClick: () -> Unit) {
+ Box(Modifier.size(44.dp), Alignment.Center) {
+ Box(
+ Modifier.size(44.dp).clip(RoundedCornerShape(13.dp))
+ .background(if (active) Color.White.copy(0.12f) else Color.Transparent)
+ .then(if (active) Modifier.border(1.dp, Color.White.copy(0.2f), RoundedCornerShape(13.dp)) else Modifier)
+ .clickable(remember { MutableInteractionSource() }, null, onClick = onClick),
+ Alignment.Center,
+ ) { Icon(icon, null, Modifier.size(22.dp), if (active) C.Text else C.Text3) }
+ if (active) {
+ Box(
+ Modifier.align(Alignment.CenterStart).offset((-14).dp).size(3.dp, 22.dp)
+ .background(C.Caramel, RoundedCornerShape(2.dp)),
+ )
+ }
+ }
+}
diff --git a/app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModUnavailableReasonResourcesTest.java b/app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModUnavailableReasonResourcesTest.java
new file mode 100644
index 00000000..c04c774e
--- /dev/null
+++ b/app/src/test/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/ModUnavailableReasonResourcesTest.java
@@ -0,0 +1,85 @@
+package org.chimeramc.client.launcher.core.mods.inbuilt.overlay;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+/**
+ * Every reason a module can be unavailable has its own message, and the surface names it.
+ *
+ * A single "needs game data" line for every cause told the user the wrong thing: a module waiting
+ * for an engine feed, a module a server decides, a module waiting for a backend capability and a
+ * module waiting for a present hook are four different situations with four different answers. This
+ * pins that the four messages exist, that the launcher-side catalogue builder words each reason id
+ * with the matching resource, and that the native menu renders the wording it is handed rather than
+ * a raw id.
+ *
+ * A build machine has no {@code R} class, so this reads the real {@code strings.xml} and source
+ * rather than a resource id, the same way the layout guards do.
+ */
+public class ModUnavailableReasonResourcesTest {
+
+ private static final String[] REASON_STRINGS = {
+ "mod_unavailable_reason",
+ "mod_unavailable_reason_server",
+ "mod_unavailable_reason_backend",
+ "mod_unavailable_reason_hook",
+ };
+
+ /** The one Java surface that words an unavailable reason for the native menu. */
+ private static final String SURFACE =
+ "app/src/main/java/org/chimeramc/client/launcher/core/mods/inbuilt/overlay/"
+ + "InGameMenuController.java";
+
+ @Test
+ public void everyReasonHasItsOwnMessage() throws IOException {
+ String strings = read("app/src/main/res/values/strings.xml");
+ for (String name : REASON_STRINGS) {
+ assertTrue("values/strings.xml must define " + name,
+ strings.contains("name=\"" + name + "\""));
+ }
+ }
+
+ @Test
+ public void theCatalogueBuilderNamesEveryReason() throws IOException {
+ String source = read(SURFACE);
+ for (String name : REASON_STRINGS) {
+ assertTrue(SURFACE + " must reference R.string." + name
+ + " so the reason is shown with the right wording",
+ source.contains("R.string." + name));
+ }
+ }
+
+ @Test
+ public void theNativeMenuRendersTheWordingItIsHandedNotARawId() throws IOException {
+ // The reason crosses as the already-worded message; the native side draws it as a status
+ // label, so a raw id can never reach the screen.
+ String render = read("app/src/main/cpp/sweetloader/src/pl/menu/MenuRender.cpp");
+ assertTrue("the native menu must draw the module's unavailable wording",
+ render.contains("m.unavailableReason"));
+ assertFalse("the menu must not render a raw reason id",
+ render.contains("\"no_game_data\"") || render.contains("\"server_decided\"")
+ || render.contains("\"no_backend\"") || render.contains("\"no_hook\""));
+ }
+
+ private static Path resolve(String relativePath) {
+ Path dir = Paths.get("").toAbsolutePath();
+ for (int i = 0; i < 6 && dir != null; i++) {
+ Path candidate = dir.resolve(relativePath);
+ if (Files.exists(candidate)) return candidate;
+ dir = dir.getParent();
+ }
+ throw new IllegalStateException("could not find " + relativePath);
+ }
+
+ private static String read(String relativePath) throws IOException {
+ return new String(Files.readAllBytes(resolve(relativePath)), StandardCharsets.UTF_8);
+ }
+}
>(emptyList())
+ var version by mutableStateOf
>(emptyList())
+ var servers by mutableStateOf
>(emptyList())
+ var cosmetics by mutableStateOf
>(emptyList())
+ var news by mutableStateOf
>(emptyList())
+
+ var launchMenu by mutableStateOf(false)
+ var instanceSettings by mutableStateOf(false)
+ var accountMenu by mutableStateOf(false)
+ var addFriend by mutableStateOf(false)
+ var launcherSettings by mutableStateOf(false)
+ var appMenu by mutableStateOf(false)
+ var screenshots by mutableStateOf(false)
+ var friendsAll by mutableStateOf(false)
+ var addAccount by mutableStateOf(false)
+ var accountSettings by mutableStateOf(false)
+
+ /** The selected mod row, or null when the detail modal is closed. */
+ var selectedMod by mutableStateOf