diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0e9d85b91..4f39efe32 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -252,8 +252,15 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.compose)
implementation(libs.landscapist.coil)
+ implementation(libs.coil.svg)
debugImplementation(libs.androidx.ui.tooling)
+ // Media3 (ExoPlayer) for video playback
+ implementation(libs.bundles.media3)
+
+ // Baseline Profiles - enables precompilation of critical code paths
+ implementation(libs.profileinstaller)
+
// Support
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
diff --git a/app/src/main/assets/Themes/ListView/assets/theme.png b/app/src/main/assets/Themes/ListView/assets/theme.png
new file mode 100644
index 000000000..a3f9c78d8
Binary files /dev/null and b/app/src/main/assets/Themes/ListView/assets/theme.png differ
diff --git a/app/src/main/assets/Themes/ListView/locales/da.xml b/app/src/main/assets/Themes/ListView/locales/da.xml
new file mode 100644
index 000000000..4f28e5033
--- /dev/null
+++ b/app/src/main/assets/Themes/ListView/locales/da.xml
@@ -0,0 +1,5 @@
+
+
+ Åbn
+
+
diff --git a/app/src/main/assets/Themes/ListView/locales/default.xml b/app/src/main/assets/Themes/ListView/locales/default.xml
new file mode 100644
index 000000000..aefd6854b
--- /dev/null
+++ b/app/src/main/assets/Themes/ListView/locales/default.xml
@@ -0,0 +1,5 @@
+
+
+ Open
+
+
diff --git a/app/src/main/assets/Themes/ListView/locales/pt-rBR.xml b/app/src/main/assets/Themes/ListView/locales/pt-rBR.xml
new file mode 100644
index 000000000..064b4d4fc
--- /dev/null
+++ b/app/src/main/assets/Themes/ListView/locales/pt-rBR.xml
@@ -0,0 +1,5 @@
+
+
+ Abrir
+
+
diff --git a/app/src/main/assets/Themes/ListView/locales/zh-rTW.xml b/app/src/main/assets/Themes/ListView/locales/zh-rTW.xml
new file mode 100644
index 000000000..c04470a0c
--- /dev/null
+++ b/app/src/main/assets/Themes/ListView/locales/zh-rTW.xml
@@ -0,0 +1,5 @@
+
+
+ 開啟
+
+
diff --git a/app/src/main/assets/Themes/ListView/manifest.xml b/app/src/main/assets/Themes/ListView/manifest.xml
new file mode 100644
index 000000000..3e548d579
--- /dev/null
+++ b/app/src/main/assets/Themes/ListView/manifest.xml
@@ -0,0 +1,14 @@
+
+ list_view
+ List View
+ 1.0.0
+ 1.0.0
+ 0.0.0
+ A traditional list layout with game details, install status, and quick actions.
+
+ Utkarsh Dalal
+ https://github.com/utkarshdalal
+
+
+
+
diff --git a/app/src/main/assets/Themes/ListView/theme.xml b/app/src/main/assets/Themes/ListView/theme.xml
new file mode 100644
index 000000000..645975a93
--- /dev/null
+++ b/app/src/main/assets/Themes/ListView/theme.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/Themes/ListView/variables.xml b/app/src/main/assets/Themes/ListView/variables.xml
new file mode 100644
index 000000000..4b99480a9
--- /dev/null
+++ b/app/src/main/assets/Themes/ListView/variables.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/baseline-prof.txt b/app/src/main/baseline-prof.txt
new file mode 100644
index 000000000..0539f851c
--- /dev/null
+++ b/app/src/main/baseline-prof.txt
@@ -0,0 +1,136 @@
+# Baseline Profile Rules for GameNative
+# These rules tell the Android Runtime (ART) which code paths should be
+# precompiled at install time for better startup and runtime performance.
+#
+# Format: HSPLclass_name;method_name(parameters)return_type
+# H = Hot (frequently called), S = Startup, P = Post-startup, L = Class
+#
+# Reference: https://developer.android.com/topic/performance/baselineprofiles
+
+# =============================================================================
+# Theme Runtime - Carousel Rendering (Critical for smooth scrolling)
+# =============================================================================
+
+# ThemedGameGrid - Main carousel/grid rendering
+HSPLapp/gamenative/theme/runtime/ThemedGameGridKt;->**(**)**
+HSPLapp/gamenative/theme/runtime/ThemedGameGrid_**;->**(**)**
+
+# Theme utilities used during rendering
+HSPLapp/gamenative/theme/runtime/ThemeUtils;->**(**)**
+HSPLapp/gamenative/theme/runtime/ThemeUtilsKt;->**(**)**
+
+# Layer renderers for card content
+HSPLapp/gamenative/theme/runtime/layers/LayerRenderersKt;->**(**)**
+HSPLapp/gamenative/theme/runtime/SharedElementRenderers;->**(**)**
+
+# Fixed element rendering
+HSPLapp/gamenative/theme/runtime/FixedElementRendererKt;->**(**)**
+
+# Binding context for data resolution
+HSPLapp/gamenative/theme/runtime/BindingContext;->**(**)**
+HSPLapp/gamenative/theme/runtime/BindingContextKt;->**(**)**
+
+# =============================================================================
+# Theme Model Classes
+# =============================================================================
+
+# Layout models
+HSPLapp/gamenative/theme/model/Layout**;->**(**)**
+HSPLapp/gamenative/theme/model/Layers**;->**(**)**
+HSPLapp/gamenative/theme/model/Fixed**;->**(**)**
+HSPLapp/gamenative/theme/model/Card**;->**(**)**
+HSPLapp/gamenative/theme/model/Dimension**;->**(**)**
+
+# =============================================================================
+# Library Screen Components
+# =============================================================================
+
+# Main library screen
+HSPLapp/gamenative/ui/screen/library/LibraryScreenKt;->**(**)**
+HSPLapp/gamenative/ui/screen/library/LibraryListPaneKt;->**(**)**
+
+# Library components
+HSPLapp/gamenative/ui/screen/library/components/**;->**(**)**
+
+# =============================================================================
+# Compose Foundation - Pager (HorizontalPager for carousels)
+# =============================================================================
+
+# Pager core
+HSPLandroidx/compose/foundation/pager/**;->**(**)**
+
+# Lazy layout infrastructure (used by pager)
+HSPLandroidx/compose/foundation/lazy/layout/**;->**(**)**
+
+# Gestures for scrolling
+HSPLandroidx/compose/foundation/gestures/**;->**(**)**
+
+# =============================================================================
+# Compose Animation (Smooth transitions)
+# =============================================================================
+
+HSPLandroidx/compose/animation/core/**;->**(**)**
+HSPLandroidx/compose/animation/**;->**(**)**
+
+# =============================================================================
+# Compose UI Graphics Layer (Scale, alpha, offset transforms)
+# =============================================================================
+
+HSPLandroidx/compose/ui/graphics/**;->**(**)**
+HSPLandroidx/compose/ui/draw/**;->**(**)**
+
+# Layout and measurement
+HSPLandroidx/compose/ui/layout/**;->**(**)**
+
+# Modifier chains
+HSPLandroidx/compose/ui/Modifier**;->**(**)**
+
+# =============================================================================
+# Image Loading (Coil - for game covers)
+# =============================================================================
+
+HSPLcoil/**;->**(**)**
+HSPLcom/skydoves/landscapist/**;->**(**)**
+
+# =============================================================================
+# Compose Material3 Components
+# =============================================================================
+
+HSPLandroidx/compose/material3/**;->**(**)**
+
+# =============================================================================
+# Kotlin Coroutines (Async operations during scrolling)
+# =============================================================================
+
+HSPLkotlinx/coroutines/**;->**(**)**
+
+# =============================================================================
+# Data Classes and ViewModels
+# =============================================================================
+
+HSPLapp/gamenative/data/LibraryItem;->**(**)**
+HSPLapp/gamenative/data/LibraryItem**;->**(**)**
+HSPLapp/gamenative/ui/data/LibraryState;->**(**)**
+HSPLapp/gamenative/ui/model/LibraryViewModel;->**(**)**
+
+# =============================================================================
+# Theme Manager and Parser
+# =============================================================================
+
+HSPLapp/gamenative/theme/ThemeManager;->**(**)**
+HSPLapp/gamenative/theme/io/ThemeXmlMapper;->**(**)**
+HSPLapp/gamenative/theme/io/ThemeXmlMapperKt;->**(**)**
+
+# =============================================================================
+# Startup Classes
+# =============================================================================
+
+# Application class
+HSPLapp/gamenative/PluviaApp;->**(**)**
+
+# Main Activity
+HSPLapp/gamenative/PluviaActivity;->**(**)**
+HSPLapp/gamenative/MainActivity;->**(**)**
+
+# Navigation
+HSPLapp/gamenative/ui/PluviaMain**;->**(**)**
diff --git a/app/src/main/java/app/gamenative/MainActivity.kt b/app/src/main/java/app/gamenative/MainActivity.kt
index 38c82e8c2..634c805fd 100644
--- a/app/src/main/java/app/gamenative/MainActivity.kt
+++ b/app/src/main/java/app/gamenative/MainActivity.kt
@@ -61,6 +61,13 @@ class MainActivity : ComponentActivity() {
private var currentOrientationChangeValue: Int = 0
private var availableOrientations: EnumSet = EnumSet.of(Orientation.UNSPECIFIED)
+ // Configuration state to trigger Compose recomposition
+ // This is needed because android:configChanges prevents automatic recreation
+ // and LocalConfiguration doesn't update automatically
+ val configurationChangeCounter = mutableStateOf(0)
+ val currentOrientation = mutableStateOf(Configuration.ORIENTATION_UNDEFINED)
+ val currentScreenWidthDp = mutableStateOf(0)
+
// Store pending launch request to be processed after UI is ready
@Volatile
private var pendingLaunchRequest: IntentLaunchManager.LaunchRequest? = null
@@ -129,6 +136,11 @@ class MainActivity : ComponentActivity() {
navigationBarStyle = SystemBarStyle.light(TRANSPARENT, TRANSPARENT),
)
super.onCreate(savedInstanceState)
+
+ // Initialize configuration state for Compose
+ val config = resources.configuration
+ currentOrientation.value = config.orientation
+ currentScreenWidthDp.value = config.screenWidthDp
// Initialize the controller management system
ControllerManager.getInstance().init(getApplicationContext());
@@ -182,6 +194,7 @@ class MainActivity : ComponentActivity() {
.components {
add(IconDecoder.Factory())
add(AnimatedPngDecoder.Factory())
+ add(coil.decode.SvgDecoder.Factory())
}
// .logger(logger)
.build()
@@ -352,7 +365,11 @@ class MainActivity : ComponentActivity() {
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
- // Log.d("MainActivity", "Requested orientation: $requestedOrientation => ${Orientation.fromActivityInfoValue(requestedOrientation)}")
+ // Update configuration state to trigger Compose recomposition
+ // This is needed because LocalConfiguration doesn't update with android:configChanges
+ currentOrientation.value = newConfig.orientation
+ currentScreenWidthDp.value = newConfig.screenWidthDp
+ configurationChangeCounter.value++
}
private fun startOrientator() {
diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt
index c6a518d0b..dfebf0121 100644
--- a/app/src/main/java/app/gamenative/PluviaApp.kt
+++ b/app/src/main/java/app/gamenative/PluviaApp.kt
@@ -67,6 +67,9 @@ class PluviaApp : SplitCompatApplication() {
// Initialize GOGConstants
app.gamenative.service.gog.GOGConstants.init(this)
+ // Initialize ThemeManager (scan themes, migrate old layout to theme, and activate)
+ app.gamenative.theme.ThemeManager.init(this)
+
DownloadService.populateDownloadService(this)
appScope.launch {
diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt
index b79d5ce53..39900f696 100644
--- a/app/src/main/java/app/gamenative/PrefManager.kt
+++ b/app/src/main/java/app/gamenative/PrefManager.kt
@@ -844,4 +844,57 @@ object PrefManager {
var appLanguage: String
get() = getPref(APP_LANGUAGE, "")
set(value) = setPref(APP_LANGUAGE, value)
+
+ // Active Theme Engine theme id (for ThemeManager)
+ private val ACTIVE_THEME_ID = stringPreferencesKey("active_theme_id")
+ var activeThemeId: String
+ get() = getPref(ACTIVE_THEME_ID, "")
+ set(value) = setPref(ACTIVE_THEME_ID, value)
+
+ // One-time migration flag: legacy Library layout -> ThemeManager theme id
+ private val THEME_LAYOUT_MIGRATED = booleanPreferencesKey("theme_layout_migrated")
+ var themeLayoutMigrated: Boolean
+ get() = getPref(THEME_LAYOUT_MIGRATED, false)
+ set(value) = setPref(THEME_LAYOUT_MIGRATED, value)
+
+ // Experimental: Use Theme Engine UI for Library screen
+ private val USE_THEME_ENGINE_UI = booleanPreferencesKey("use_theme_engine_ui")
+ var useThemeEngineUi: Boolean
+ get() = getPref(USE_THEME_ENGINE_UI, false)
+ set(value) = setPref(USE_THEME_ENGINE_UI, value)
+
+ // Sort installed games by last played time
+ private val SORT_INSTALLED_BY_LAST_PLAYED = booleanPreferencesKey("sort_installed_by_last_played")
+ var sortInstalledByLastPlayed: Boolean
+ get() = getPref(SORT_INSTALLED_BY_LAST_PLAYED, false)
+ set(value) = setPref(SORT_INSTALLED_BY_LAST_PLAYED, value)
+
+ // Show dialog when adding external theme
+ private val SHOW_ADD_THEME_DIALOG = booleanPreferencesKey("show_add_theme_dialog")
+ var showAddThemeDialog: Boolean
+ get() = getPref(SHOW_ADD_THEME_DIALOG, true)
+ set(value) = setPref(SHOW_ADD_THEME_DIALOG, value)
+
+ // Pending theme added toast (stores theme name to show toast after app restart)
+ private val PENDING_THEME_ADDED_TOAST = stringPreferencesKey("pending_theme_added_toast")
+ var pendingThemeAddedToast: String?
+ get() = getPref(PENDING_THEME_ADDED_TOAST, "").takeIf { it.isNotBlank() }
+ set(value) = setPref(PENDING_THEME_ADDED_TOAST, value ?: "")
+
+ // External theme paths (comma-separated list of folder paths)
+ private val EXTERNAL_THEME_PATHS = stringPreferencesKey("external_theme_paths")
+ var externalThemePaths: Set
+ get() {
+ val raw = getPref(EXTERNAL_THEME_PATHS, "")
+ return if (raw.isBlank()) emptySet() else raw.split("|||").toSet()
+ }
+ set(value) = setPref(EXTERNAL_THEME_PATHS, value.joinToString("|||"))
+
+ fun addExternalThemePath(path: String) {
+ externalThemePaths = externalThemePaths + path
+ }
+
+ fun removeExternalThemePath(path: String) {
+ externalThemePaths = externalThemePaths - path
+ }
}
diff --git a/app/src/main/java/app/gamenative/theme/ThemeManager.kt b/app/src/main/java/app/gamenative/theme/ThemeManager.kt
new file mode 100644
index 000000000..169f60c46
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/ThemeManager.kt
@@ -0,0 +1,710 @@
+package app.gamenative.theme
+
+import android.content.Context
+import android.widget.Toast
+import app.gamenative.BuildConfig
+import app.gamenative.PrefManager
+import app.gamenative.R
+import app.gamenative.theme.io.ThemeLoader
+import app.gamenative.theme.io.ThemeXmlMapper
+import app.gamenative.theme.model.ThemeDefinition
+import app.gamenative.theme.model.ThemeEngine
+import app.gamenative.theme.model.ThemeLoadResult
+import app.gamenative.theme.validate.ThemeValidator
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import timber.log.Timber
+import java.io.File
+import java.io.InputStream
+import java.nio.file.Paths
+
+/**
+ * ThemeManager: enumerates themes (built-in assets and user directory), persists selection,
+ * loads manifests with compatibility checks, and exposes a dev-only hot reload.
+ *
+ * This step keeps integration minimal: we parse only the manifest and notify listeners
+ * when selection or reload occurs. Rendering hookup will follow in later steps.
+ */
+object ThemeManager {
+
+ enum class Source { BuiltIn, User, External }
+
+ data class ThemeEntry(
+ val id: String,
+ val name: String, // display name from manifest title
+ val source: Source,
+ val location: String, // folder path or asset subfolder
+ val manifest: ManifestLite,
+ )
+
+ data class ManifestLite(
+ val id: String,
+ val title: String, // human-readable display name
+ val version: String,
+ /**
+ * Engine version constraint (Composer-like syntax).
+ * Examples: "1.0.0", "1.*", "^1.0.0", "~1.2.0"
+ */
+ val engineVersion: String,
+ val minAppVersion: String,
+ val maxAppVersion: String?,
+ val description: String? = null,
+ val author: String? = null,
+ val authorGithub: String? = null,
+ val previewImage: String? = null, // relative path to preview image (e.g., "/assets/theme.png")
+ )
+
+ private lateinit var appCtx: Context
+
+ private val scope = CoroutineScope(Dispatchers.Main)
+
+ private val _availableThemes = MutableStateFlow>(emptyList())
+ val availableThemes: StateFlow> = _availableThemes.asStateFlow()
+
+ private val _selectedThemeId = MutableStateFlow(null)
+ val selectedThemeId: StateFlow = _selectedThemeId.asStateFlow()
+
+ // Emit a monotonically increasing token whenever we re-parse the active theme (dev-only reload)
+ private val _reloadTick = MutableStateFlow(0)
+ val reloadTick: StateFlow = _reloadTick.asStateFlow()
+
+ private val _activeTheme = MutableStateFlow(null)
+ val activeTheme: StateFlow = _activeTheme.asStateFlow()
+
+ // Store raw theme tree for re-mapping on orientation changes
+ private var activeThemeTree: app.gamenative.theme.model.ThemeTree? = null
+ private var lastMappedIsPortrait: Boolean? = null
+ private var lastMappedScreenWidth: Int? = null
+
+ // Store the root directory of the active theme for asset resolution
+ private val _activeThemeRootDir = MutableStateFlow(null)
+ val activeThemeRootDir: StateFlow = _activeThemeRootDir.asStateFlow()
+
+ private const val ASSETS_THEMES_ROOT = "Themes"
+ private const val FALLBACK_THEME_ID = "list_view"
+
+ fun init(context: Context) {
+ appCtx = context.applicationContext
+ // Perform one-time migration from legacy layout preference to theme id, before scanning
+ migrateLegacyLayoutToThemeIfNeeded()
+ // Initial scan + resolve selection
+ scope.launch(Dispatchers.IO) {
+ val list = scanAllThemes()
+ _availableThemes.value = list
+ val persisted = PrefManager.activeThemeId.ifBlank { FALLBACK_THEME_ID }
+ val chosen = list.find { it.id.equals(persisted, ignoreCase = true) }
+ ?: pickFallback(list)
+ _selectedThemeId.value = chosen?.id
+ if (chosen == null) {
+ Timber.w("No themes found; ThemeManager availableThemes is empty")
+ } else {
+ // Load full theme definition
+ loadAndActivateTheme(chosen)
+ }
+ }
+ }
+
+ /**
+ * One-time migration: map old Library layout preference to a Theme id.
+ * This only sets PrefManager.activeThemeId if not already chosen and if migration wasn't done.
+ */
+ private fun migrateLegacyLayoutToThemeIfNeeded() {
+ try {
+ if (PrefManager.themeLayoutMigrated) return
+ // If user already has an active theme set, skip
+ if (PrefManager.activeThemeId.isNotBlank()) {
+ PrefManager.themeLayoutMigrated = true
+ return
+ }
+ val layout = PrefManager.libraryLayout
+ val themeId = when (layout.name) {
+ // Map rough equivalents
+ "LIST" -> "list_view"
+ "GRID_CAPSULE" -> "capsule_grid"
+ "GRID_HERO" -> "hero_grid"
+ else -> FALLBACK_THEME_ID
+ }
+ PrefManager.activeThemeId = themeId
+ PrefManager.themeLayoutMigrated = true
+ Timber.i("Migrated legacy layout '%s' to theme id '%s'", layout.name, themeId)
+ } catch (t: Throwable) {
+ Timber.e(t, "migrateLegacyLayoutToThemeIfNeeded failed")
+ }
+ }
+
+ fun getSelectedThemeEntry(): ThemeEntry? = _availableThemes.value.firstOrNull { it.id == _selectedThemeId.value }
+
+ /**
+ * Get the asset path for string resolution in the currently selected theme.
+ * For built-in themes: "Themes/"
+ * For user themes: returns the user theme location
+ */
+ fun getActiveThemeAssetPath(): String? {
+ val entry = getSelectedThemeEntry() ?: return null
+ return getThemeAssetPath(entry)
+ }
+
+ /**
+ * Get the asset path for any theme entry.
+ * For built-in themes: "Themes/"
+ * For user themes: returns the user theme location (absolute path)
+ */
+ fun getThemeAssetPath(entry: ThemeEntry): String {
+ return when (entry.source) {
+ Source.BuiltIn -> "$ASSETS_THEMES_ROOT/${entry.location}"
+ Source.User, Source.External -> entry.location
+ }
+ }
+
+ fun selectTheme(id: String) {
+ val match = _availableThemes.value.firstOrNull { it.id == id }
+ if (match == null) {
+ Timber.w("Attempted to select unknown theme id=%s", id)
+ return
+ }
+ _selectedThemeId.value = match.id
+ PrefManager.activeThemeId = match.id
+ scope.launch(Dispatchers.Main) {
+ Toast.makeText(appCtx, appCtx.getString(R.string.theme_applied_toast, match.name), Toast.LENGTH_SHORT).show()
+ }
+ Timber.i("Theme selected: %s (%s)", match.id, match.source)
+ // Load full theme definition
+ scope.launch(Dispatchers.IO) {
+ try {
+ loadAndActivateTheme(match)
+ } catch (t: Throwable) {
+ Timber.e(t, "Failed to activate theme after selection")
+ }
+ }
+ }
+
+ fun reloadActiveThemeDevOnly() {
+ if (!BuildConfig.DEBUG) return
+ val current = getSelectedThemeEntry() ?: return
+ scope.launch(Dispatchers.IO) {
+ try {
+ // Re-parse manifests to reflect any changes on disk/assets
+ val list = scanAllThemes()
+ _availableThemes.value = list
+ val stillThere = list.find { it.id == current.id }
+ if (stillThere == null) {
+ Timber.w("Active theme missing after reload; falling back")
+ applyFallbackWithToast(list)
+ pickFallback(list)?.let { fb -> loadAndActivateTheme(fb) }
+ } else {
+ // Re-load full theme definition
+ loadAndActivateTheme(stillThere)
+ // Bump reload tick so observers can diff-apply changes if needed
+ _reloadTick.value = _reloadTick.value + 1
+ Timber.i("Theme reloaded (dev): %s", current.id)
+ }
+ } catch (t: Throwable) {
+ Timber.e(t, "Reload failed; falling back")
+ val list2 = scanAllThemes()
+ applyFallbackWithToast(list2)
+ pickFallback(list2)?.let { fb -> loadAndActivateTheme(fb) }
+ }
+ }
+ }
+
+ // --- Scanning & parsing ---
+
+ private fun scanAllThemes(): List {
+ val builtIns = scanBuiltInThemes()
+ val users = scanUserThemes()
+ val externals = scanExternalThemes()
+ return (builtIns + users + externals)
+ .distinctBy { it.id }
+ .sortedWith(compareBy { it.source }.thenBy { it.id.lowercase() })
+ }
+
+ private fun scanBuiltInThemes(): List {
+ val results = mutableListOf()
+ try {
+ val subdirs = appCtx.assets.list(ASSETS_THEMES_ROOT) ?: emptyArray()
+ subdirs.forEach { dir ->
+ val manifestPath = "$ASSETS_THEMES_ROOT/$dir/manifest.xml"
+ val manifest = parseManifest(appCtx.assets.open(manifestPath)) ?: return@forEach
+ if (!isCompatible(manifest)) return@forEach
+ results += ThemeEntry(
+ id = manifest.id,
+ name = manifest.title,
+ source = Source.BuiltIn,
+ location = dir,
+ manifest = manifest,
+ )
+ }
+ } catch (t: Throwable) {
+ Timber.e(t, "scanBuiltInThemes failed")
+ }
+ return results
+ }
+
+ private fun scanUserThemes(): List {
+ val results = mutableListOf()
+ return try {
+ val root = getUserThemesRoot() ?: return emptyList()
+ if (!root.exists() || !root.isDirectory) return emptyList()
+ root.listFiles()?.forEach { dir ->
+ if (!dir.isDirectory) return@forEach
+ val manifestFile = File(dir, "manifest.xml")
+ if (!manifestFile.exists()) return@forEach
+ val manifest = manifestFile.inputStream().use { parseManifest(it) } ?: return@forEach
+ if (!isCompatible(manifest)) return@forEach
+ results += ThemeEntry(
+ id = manifest.id,
+ name = manifest.title,
+ source = Source.User,
+ location = dir.absolutePath,
+ manifest = manifest,
+ )
+ }
+ results
+ } catch (t: Throwable) {
+ Timber.e(t, "scanUserThemes failed")
+ results
+ }
+ }
+
+ private fun getUserThemesRoot(): File? {
+ val ext = PrefManager.externalStoragePath
+ if (ext.isBlank()) return null
+ val p = Paths.get(ext, "GameNative", "Themes").toFile()
+ return p
+ }
+
+ /**
+ * Scan themes from externally added paths (user-selected folders).
+ */
+ private fun scanExternalThemes(): List {
+ val results = mutableListOf()
+ val paths = PrefManager.externalThemePaths
+ if (paths.isEmpty()) return results
+
+ for (path in paths) {
+ try {
+ val dir = File(path)
+ if (!dir.exists() || !dir.isDirectory) {
+ Timber.w("External theme path does not exist or is not a directory: %s", path)
+ continue
+ }
+ val manifestFile = File(dir, "manifest.xml")
+ if (!manifestFile.exists()) {
+ Timber.w("External theme missing manifest.xml: %s", path)
+ continue
+ }
+ val manifest = manifestFile.inputStream().use { parseManifest(it) }
+ if (manifest == null) {
+ Timber.w("Failed to parse manifest for external theme: %s", path)
+ continue
+ }
+ if (!isCompatible(manifest)) {
+ Timber.w("External theme not compatible: %s", path)
+ continue
+ }
+ results += ThemeEntry(
+ id = manifest.id,
+ name = manifest.title,
+ source = Source.External,
+ location = dir.absolutePath,
+ manifest = manifest,
+ )
+ } catch (t: Throwable) {
+ Timber.e(t, "Error scanning external theme at %s", path)
+ }
+ }
+ return results
+ }
+
+ /**
+ * Result of validating an external theme folder.
+ */
+ sealed class ThemeValidationResult {
+ data class Valid(val manifest: ManifestLite) : ThemeValidationResult()
+ data class Invalid(val error: String) : ThemeValidationResult()
+ }
+
+ /**
+ * Validate a folder to check if it contains a valid theme.
+ * @param folderPath Absolute path to the theme folder
+ * @return Validation result with manifest info or error message
+ */
+ fun validateThemeFolder(folderPath: String): ThemeValidationResult {
+ return try {
+ val dir = File(folderPath)
+ if (!dir.exists()) {
+ return ThemeValidationResult.Invalid("Folder does not exist")
+ }
+ if (!dir.isDirectory) {
+ return ThemeValidationResult.Invalid("Path is not a directory")
+ }
+
+ val manifestFile = File(dir, "manifest.xml")
+ if (!manifestFile.exists()) {
+ return ThemeValidationResult.Invalid("Missing manifest.xml")
+ }
+
+ val manifest = manifestFile.inputStream().use { parseManifest(it) }
+ ?: return ThemeValidationResult.Invalid("Invalid manifest.xml: missing required fields (id, version, engineVersion, minAppVersion)")
+
+ // Check if theme with same ID already exists
+ val existing = _availableThemes.value.find { it.id == manifest.id }
+ if (existing != null) {
+ return ThemeValidationResult.Invalid("A theme with ID '${manifest.id}' already exists")
+ }
+
+ if (!isCompatible(manifest)) {
+ return ThemeValidationResult.Invalid("Theme is not compatible with this version of the app (engine version: ${manifest.engineVersion})")
+ }
+
+ ThemeValidationResult.Valid(manifest)
+ } catch (t: Throwable) {
+ Timber.e(t, "Error validating theme folder: %s", folderPath)
+ ThemeValidationResult.Invalid("Error reading theme: ${t.message}")
+ }
+ }
+
+ /**
+ * Add an external theme by folder path.
+ * @param folderPath Absolute path to the theme folder
+ * @return The added ThemeEntry on success, null on failure
+ */
+ fun addExternalTheme(folderPath: String): ThemeEntry? {
+ val validation = validateThemeFolder(folderPath)
+ if (validation is ThemeValidationResult.Invalid) {
+ Timber.w("Cannot add external theme: %s", validation.error)
+ return null
+ }
+
+ val manifest = (validation as ThemeValidationResult.Valid).manifest
+
+ // Add to preferences
+ PrefManager.addExternalThemePath(folderPath)
+
+ // Create entry
+ val entry = ThemeEntry(
+ id = manifest.id,
+ name = manifest.title,
+ source = Source.External,
+ location = folderPath,
+ manifest = manifest,
+ )
+
+ // Update available themes list
+ scope.launch(Dispatchers.IO) {
+ val list = scanAllThemes()
+ _availableThemes.value = list
+ }
+
+ Timber.i("Added external theme: %s at %s", manifest.id, folderPath)
+ return entry
+ }
+
+ /**
+ * Remove an external theme.
+ * This removes the theme from the list but does NOT delete the files.
+ * @param themeId The ID of the theme to remove
+ * @return True if removed, false if not found or not an external theme
+ */
+ fun removeExternalTheme(themeId: String): Boolean {
+ val entry = _availableThemes.value.find { it.id == themeId }
+ if (entry == null) {
+ Timber.w("Cannot remove external theme: not found with id=%s", themeId)
+ return false
+ }
+ if (entry.source != Source.External) {
+ Timber.w("Cannot remove theme %s: not an external theme (source=%s)", themeId, entry.source)
+ return false
+ }
+
+ // Remove from preferences
+ PrefManager.removeExternalThemePath(entry.location)
+
+ // Immediately update the list by filtering out the removed theme
+ val updatedList = _availableThemes.value.filter { it.id != themeId }
+ _availableThemes.value = updatedList
+
+ // If this was the selected theme, switch to fallback
+ if (_selectedThemeId.value == themeId) {
+ scope.launch(Dispatchers.IO) {
+ applyFallbackWithToast(updatedList)
+ pickFallback(updatedList)?.let { fb -> loadAndActivateTheme(fb) }
+ }
+ }
+
+ Timber.i("Removed external theme: %s", themeId)
+ return true
+ }
+
+ private fun parseManifest(input: InputStream): ManifestLite? {
+ return try {
+ input.use {
+ val factory = XmlPullParserFactory.newInstance()
+ val parser = factory.newPullParser()
+ parser.setInput(it, null)
+ var id: String? = null
+ var title: String? = null
+ var version: String? = null
+ var engineVersion: String? = null
+ var minAppVersion: String? = null
+ var maxAppVersion: String? = null
+ var description: String? = null
+ var authorName: String? = null
+ var authorGithub: String? = null
+ var previewImage: String? = null
+ var inAuthorTag = false
+ var event = parser.eventType
+ while (event != XmlPullParser.END_DOCUMENT) {
+ if (event == XmlPullParser.START_TAG) {
+ when (parser.name.lowercase()) {
+ "id" -> id = parser.nextText()?.trim()
+ "title" -> title = parser.nextText()?.trim()
+ "version" -> version = parser.nextText()?.trim()
+ "engineversion" -> engineVersion = parser.nextText()?.trim()
+ "minappversion" -> minAppVersion = parser.nextText()?.trim()
+ "maxappversion" -> maxAppVersion = parser.nextText()?.trim()
+ "description" -> description = parser.nextText()?.trim()
+ "author" -> inAuthorTag = true
+ "name" -> if (inAuthorTag) authorName = parser.nextText()?.trim()
+ "github" -> if (inAuthorTag) authorGithub = parser.nextText()?.trim()
+ "preview" -> previewImage = parser.getAttributeValue(null, "src")?.trim()
+ }
+ } else if (event == XmlPullParser.END_TAG) {
+ if (parser.name.lowercase() == "author") inAuthorTag = false
+ }
+ event = parser.next()
+ }
+ if (id.isNullOrBlank() || version.isNullOrBlank() || engineVersion.isNullOrBlank() || minAppVersion.isNullOrBlank()) {
+ Timber.w("Manifest missing required fields")
+ null
+ } else {
+ // Fall back to id if title not specified
+ ManifestLite(
+ id = id!!,
+ title = title ?: id!!,
+ version = version!!,
+ engineVersion = engineVersion!!,
+ minAppVersion = minAppVersion!!,
+ maxAppVersion = maxAppVersion,
+ description = description,
+ author = authorName,
+ authorGithub = authorGithub,
+ previewImage = previewImage,
+ )
+ }
+ }
+ } catch (t: Throwable) {
+ Timber.e(t, "parseManifest failed")
+ null
+ }
+ }
+
+ private fun isCompatible(m: ManifestLite): Boolean {
+ if (!ThemeEngine.matchesConstraint(m.engineVersion)) {
+ Timber.i("Ignoring theme %s: engineVersion constraint '%s' does not match app engine '%s'",
+ m.id, m.engineVersion, ThemeEngine.ENGINE_VERSION)
+ return false
+ }
+ // App version compatibility: The app doesn't expose semantic version; accept all for now
+ return true
+ }
+
+ private fun pickFallback(list: List): ThemeEntry? {
+ return list.firstOrNull { it.id.equals(FALLBACK_THEME_ID, ignoreCase = true) }
+ ?: list.firstOrNull { it.source == Source.BuiltIn }
+ ?: list.firstOrNull()
+ }
+
+ private fun applyFallbackWithToast(list: List) {
+ val fb = pickFallback(list)
+ if (fb == null) {
+ Timber.w("No fallback theme available")
+ return
+ }
+ _selectedThemeId.value = fb.id
+ PrefManager.activeThemeId = fb.id
+ // Ensure toast is shown on the main thread to avoid NPE (Looper not prepared)
+ scope.launch(Dispatchers.Main) {
+ Toast.makeText(appCtx, appCtx.getString(R.string.theme_fallback_toast, fb.id), Toast.LENGTH_LONG).show()
+ }
+ Timber.i("Fell back to theme: %s", fb.id)
+ }
+
+ // --- Activation ---
+ /**
+ * Load, validate, map and activate the given theme entry.
+ * Handles built-in assets by copying into cache first.
+ */
+ private fun loadAndActivateTheme(entry: ThemeEntry) {
+ try {
+ val loadDir: File = when (entry.source) {
+ Source.User, Source.External -> File(entry.location)
+ Source.BuiltIn -> {
+ // Copy built-in assets from APK to cache dir
+ val cacheRoot = File(appCtx.cacheDir, "themes_cache")
+ val dst = File(cacheRoot, entry.location)
+ copyAssetsThemeTo(dst, entry.location)
+ dst
+ }
+ }
+
+ val loader = ThemeLoader()
+ when (val res = loader.load(loadDir.absolutePath)) {
+ is ThemeLoadResult.Success -> {
+ // Validate
+ val validation = ThemeValidator.validate(
+ res.tree,
+ appVersion = BuildConfig.VERSION_NAME,
+ engineVersion = ThemeEngine.ENGINE_VERSION,
+ )
+ if (validation.hasBlocking()) {
+ validation.issues.forEach { issue ->
+ Timber.e("[THEME_V2] %s: %s", issue.code.name, issue.message)
+ }
+ Timber.w("Validation failed for theme %s: %s", entry.id, validation.issues.joinToString { "${it.code.name}: ${it.message}" })
+ val all = _availableThemes.value
+ applyFallbackWithToast(all)
+ pickFallback(all)?.let { fb -> if (fb.id != entry.id) loadAndActivateTheme(fb) }
+ return
+ }
+ // Store raw tree for orientation-aware remapping
+ activeThemeTree = res.tree
+ lastMappedIsPortrait = null
+ lastMappedScreenWidth = null
+
+ // Store theme root directory for asset resolution
+ _activeThemeRootDir.value = loadDir.absolutePath
+
+ // Map to runtime model (initial mapping without orientation context)
+ val def: ThemeDefinition = ThemeXmlMapper.map(res.tree)
+ _activeTheme.value = def
+ Timber.i("Theme activated: %s (%s) at %s", entry.id, entry.source, loadDir.absolutePath)
+ }
+ is ThemeLoadResult.Failure -> {
+ Timber.w("Failed to load theme %s: %s", entry.id, res.errors.joinToString { it.code })
+ val all = _availableThemes.value
+ applyFallbackWithToast(all)
+ pickFallback(all)?.let { fb -> if (fb.id != entry.id) loadAndActivateTheme(fb) }
+ }
+ }
+ } catch (t: Throwable) {
+ Timber.e(t, "loadAndActivateTheme crashed for %s", entry.id)
+ val all = _availableThemes.value
+ applyFallbackWithToast(all)
+ pickFallback(all)?.let { fb -> if (fb.id != entry.id) loadAndActivateTheme(fb) }
+ }
+ }
+
+ // --- Assets copy helpers ---
+ private fun copyAssetsThemeTo(destDir: File, assetSubdir: String) {
+ try {
+ if (!destDir.exists()) destDir.mkdirs()
+ val base = "$ASSETS_THEMES_ROOT/$assetSubdir"
+ // Avoid double-nesting the top-level theme folder (destDir already points to it)
+ val children = appCtx.assets.list(base) ?: emptyArray()
+ children.forEach { child ->
+ val childAssetPath = "$base/$child"
+ val childList = try { appCtx.assets.list(childAssetPath) ?: emptyArray() } catch (_: Throwable) { emptyArray() }
+ if (childList.isEmpty()) {
+ // It's a file directly under the theme root
+ copyAssetFile(childAssetPath, File(destDir, child))
+ } else {
+ // It's a subdirectory; copy recursively into destDir/child
+ copyAssetDirRecursive(childAssetPath, File(destDir, child))
+ }
+ }
+ } catch (e: Throwable) {
+ Timber.e(e, "Failed to copy built-in theme assets to cache")
+ }
+ }
+
+ private fun copyAssetDirRecursive(assetPath: String, destDir: File) {
+ val list = try { appCtx.assets.list(assetPath) ?: emptyArray() } catch (_: Throwable) { emptyArray() }
+ if (list.isEmpty()) {
+ // It's a file
+ copyAssetFile(assetPath, File(destDir, File(assetPath).name))
+ return
+ }
+ // It's a directory
+ val dirName = assetPath.substringAfterLast('/')
+ val currentDest = if (dirName.isNotEmpty()) File(destDir, dirName) else destDir
+ if (!currentDest.exists()) currentDest.mkdirs()
+ list.forEach { child ->
+ val childAssetPath = if (assetPath.isEmpty()) child else "$assetPath/$child"
+ val childList = try { appCtx.assets.list(childAssetPath) ?: emptyArray() } catch (_: Throwable) { emptyArray() }
+ if (childList.isEmpty()) {
+ copyAssetFile(childAssetPath, File(currentDest, child))
+ } else {
+ copyAssetDirRecursive(childAssetPath, currentDest)
+ }
+ }
+ }
+
+ private fun copyAssetFile(assetPath: String, outFile: File) {
+ try {
+ if (!outFile.parentFile.exists()) outFile.parentFile.mkdirs()
+ appCtx.assets.open(assetPath).use { input ->
+ outFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+ } catch (e: Throwable) {
+ Timber.e(e, "Failed copying asset file %s", assetPath)
+ }
+ }
+
+ // --- Orientation-aware remapping ---
+
+ /**
+ * Re-map the active theme with orientation-aware variable resolution.
+ * Call this when screen orientation or size changes significantly.
+ *
+ * @param isPortrait True if current orientation is portrait
+ * @param screenWidthDp Current screen width in dp
+ * @return True if theme was remapped, false if no change needed
+ */
+ fun remapForOrientation(isPortrait: Boolean, screenWidthDp: Int): Boolean {
+ val tree = activeThemeTree ?: return false
+
+ // Skip if orientation hasn't changed
+ if (lastMappedIsPortrait == isPortrait && lastMappedScreenWidth == screenWidthDp) {
+ return false
+ }
+
+ // Skip if no breakpoints (no orientation-specific overrides)
+ if (tree.breakpoints.isEmpty()) {
+ lastMappedIsPortrait = isPortrait
+ lastMappedScreenWidth = screenWidthDp
+ return false
+ }
+
+ // Resolve variables with breakpoint overrides
+ val resolvedVariables = app.gamenative.theme.runtime.VariableResolver.resolveWithBreakpoints(
+ baseVariables = tree.variables,
+ breakpoints = tree.breakpoints,
+ isPortrait = isPortrait,
+ screenWidthDp = screenWidthDp
+ )
+
+ // Re-map theme with resolved variables
+ val def = ThemeXmlMapper.map(tree, resolvedVariables)
+ _activeTheme.value = def
+
+ lastMappedIsPortrait = isPortrait
+ lastMappedScreenWidth = screenWidthDp
+
+ Timber.d("Theme remapped for orientation: isPortrait=%s, width=%d", isPortrait, screenWidthDp)
+ return true
+ }
+
+ /**
+ * Check if the theme has any breakpoints that might need orientation-aware handling.
+ */
+ fun hasBreakpoints(): Boolean = activeThemeTree?.breakpoints?.isNotEmpty() == true
+}
diff --git a/app/src/main/java/app/gamenative/theme/io/IncludeResolver.kt b/app/src/main/java/app/gamenative/theme/io/IncludeResolver.kt
new file mode 100644
index 000000000..fdcb70943
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/io/IncludeResolver.kt
@@ -0,0 +1,208 @@
+package app.gamenative.theme.io
+
+import app.gamenative.theme.model.SourceLoc
+import app.gamenative.theme.model.ThemeLoadError
+import app.gamenative.theme.model.XmlNode
+import java.io.File
+import java.io.InputStream
+import java.nio.file.Path
+import java.nio.file.Paths
+import javax.xml.parsers.SAXParserFactory
+import org.xml.sax.Attributes
+import org.xml.sax.InputSource
+import org.xml.sax.Locator
+import org.xml.sax.helpers.DefaultHandler
+
+/**
+ * Resolves recursively and produces an XmlNode tree while
+ * preserving source file and line numbers for diagnostics.
+ */
+class IncludeResolver(
+ private val themeRootDir: String,
+) {
+ private val factory = SAXParserFactory.newInstance().apply {
+ isNamespaceAware = false
+ isValidating = false
+ try { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) } catch (_: Exception) {}
+ }
+
+ data class Result(
+ val root: XmlNode?,
+ val errors: List,
+ )
+
+ /**
+ * Parse the provided file and return an XmlNode root with includes expanded.
+ */
+ fun parseWithIncludes(filePath: String): Result {
+ val errors = mutableListOf()
+ val visited = ArrayDeque()
+ val root = try {
+ parseInternal(Paths.get(filePath).normalize(), visited, errors)
+ } catch (e: Exception) {
+ errors += ThemeLoadError(
+ code = "IO_PARSE_ERROR",
+ message = "Failed to parse XML '${filePath}': ${e.message}",
+ source = SourceLoc(filePath),
+ )
+ null
+ }
+ return Result(root, errors)
+ }
+
+ private fun parseInternal(file: Path, stack: ArrayDeque, errors: MutableList): XmlNode? {
+ val themeRoot = Paths.get(themeRootDir).normalize()
+ val normalized = file.normalize()
+ if (!normalized.startsWith(themeRoot)) {
+ errors += ThemeLoadError(
+ code = "PATH_ESCAPE",
+ message = "Include path escapes theme root: '${normalized}'",
+ source = SourceLoc(normalized.toString()),
+ )
+ return null
+ }
+ if (!File(normalized.toString()).exists()) {
+ errors += ThemeLoadError(
+ code = "FILE_NOT_FOUND",
+ message = "XML file not found: '${normalized}'",
+ source = SourceLoc(normalized.toString()),
+ )
+ return null
+ }
+ if (stack.contains(normalized)) {
+ errors += ThemeLoadError(
+ code = "INCLUDE_CYCLE",
+ message = "Cyclic include detected: ${stack.joinToString(" -> ") { it.fileName.toString() }} -> ${normalized.fileName}",
+ source = SourceLoc(normalized.toString()),
+ )
+ return null
+ }
+
+ stack.addLast(normalized)
+ val parser = factory.newSAXParser()
+ val handler = BuildNodeHandler(normalized.toString()) { includeSrc, includeLoc ->
+ // Resolve include path relative to current file
+ val incPath = resolvePath(normalized.parent ?: themeRoot, includeSrc)
+ val included = parseInternal(incPath, stack, errors)
+ if (included == null) {
+ errors += ThemeLoadError(
+ code = "INCLUDE_LOAD_FAILED",
+ message = "Failed to load include '${includeSrc}' from '${normalized.fileName}'",
+ source = includeLoc,
+ )
+ emptyList()
+ } else {
+ // If included root matches parent will be unpacked by caller; here we just attach as a node
+ listOf(included)
+ }
+ }
+ File(normalized.toString()).inputStream().use { input: InputStream ->
+ parser.parse(InputSource(input), handler)
+ }
+ stack.removeLast()
+ return handler.resultRoot
+ }
+
+ private fun resolvePath(baseDir: Path, raw: String): Path {
+ return if (raw.startsWith("/")) {
+ // Rooted at theme root
+ Paths.get(themeRootDir).resolve(raw.removePrefix("/")).normalize()
+ } else {
+ baseDir.resolve(raw).normalize()
+ }
+ }
+
+ private class BuildNodeHandler(
+ private val filePath: String,
+ private val onInclude: (src: String, loc: SourceLoc) -> List,
+ ) : DefaultHandler() {
+ private var locator: Locator? = null
+ private val nodeStack = ArrayDeque()
+ var resultRoot: XmlNode? = null
+
+ override fun setDocumentLocator(locator: Locator?) {
+ this.locator = locator
+ }
+
+ override fun startElement(uri: String?, localName: String?, qName: String, attributes: Attributes) {
+ val name = qName
+ val srcLoc = SourceLoc(
+ filePath = filePath,
+ line = locator?.lineNumber,
+ column = locator?.columnNumber,
+ )
+ if (name == "include") {
+ val includeSrc = attributes.getValue("src")
+ if (includeSrc != null) {
+ val includedNodes = onInclude(includeSrc, srcLoc)
+ // Attach included nodes directly into current top as children
+ if (nodeStack.isNotEmpty()) {
+ val top = nodeStack.last()
+ for (n in includedNodes) {
+ // If the included root tag matches the current parent tag, splice its children
+ if (n.name == top.tagName()) {
+ n.children.forEach { child -> top.children.add(child) }
+ } else {
+ top.children.add(n)
+ }
+ }
+ } else {
+ // If include at root level, and multiple nodes returned, wrap them under a synthetic root
+ // However, we do not expect include at root; ignore here (no-op) if no stack exists
+ }
+ }
+ // Do not push an include node itself
+ return
+ }
+ val attrMap = mutableMapOf()
+ for (i in 0 until attributes.length) {
+ val key = attributes.getQName(i)
+ val value = attributes.getValue(i)
+ attrMap[key] = value
+ }
+ nodeStack.addLast(XmlNodeBuilder(name, attrMap, srcLoc))
+ }
+
+ override fun characters(ch: CharArray, start: Int, length: Int) {
+ if (nodeStack.isEmpty()) return
+ val text = String(ch, start, length).takeIf { it.isNotBlank() } ?: return
+ nodeStack.last().appendText(text)
+ }
+
+ override fun endElement(uri: String?, localName: String?, qName: String) {
+ if (qName == "include") return // was handled in startElement
+ val finished = nodeStack.removeLast()
+ val node = finished.build()
+ if (nodeStack.isEmpty()) {
+ resultRoot = if (resultRoot == null) node else resultRoot
+ } else {
+ val parent = nodeStack.last()
+ // If the node we just built is a container with same name as parent and was sourced via include,
+ // the unwrapping is handled in ThemeLoader when grafting. Here we keep structure as-is.
+ parent.children.add(node)
+ }
+ }
+
+ private class XmlNodeBuilder(
+ private val name: String,
+ private val attributes: MutableMap,
+ private val source: SourceLoc,
+ ) {
+ fun tagName(): String = name
+ val children: MutableList = mutableListOf()
+ private var text: String? = null
+
+ fun appendText(t: String) {
+ text = ((text ?: "") + t)
+ }
+
+ fun build(): XmlNode = XmlNode(
+ name = name,
+ attributes = attributes.toMap(),
+ children = children.toList(),
+ text = text?.trim(),
+ source = source,
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/theme/io/ThemeLoader.kt b/app/src/main/java/app/gamenative/theme/io/ThemeLoader.kt
new file mode 100644
index 000000000..07cc32391
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/io/ThemeLoader.kt
@@ -0,0 +1,328 @@
+package app.gamenative.theme.io
+
+import app.gamenative.theme.model.Breakpoint
+import app.gamenative.theme.model.ManifestEntry
+import app.gamenative.theme.model.Orientation
+import app.gamenative.theme.model.SourceLoc
+import app.gamenative.theme.model.ThemeLoadError
+import app.gamenative.theme.model.ThemeLoadResult
+import app.gamenative.theme.model.ThemeTree
+import app.gamenative.theme.model.XmlNode
+import java.io.File
+import java.io.InputStream
+import java.nio.file.Path
+import java.nio.file.Paths
+import javax.xml.parsers.SAXParserFactory
+import org.xml.sax.Attributes
+import org.xml.sax.InputSource
+import org.xml.sax.Locator
+import org.xml.sax.helpers.DefaultHandler
+
+/**
+ * Loads a Theme folder: reads manifest.xml (if present), parses theme.xml,
+ * resolves and , and returns a ThemeTree
+ * preserving file and line information for diagnostics.
+ */
+class ThemeLoader {
+
+ /** Load a theme from the given folder path. */
+ fun load(themeDirPath: String): ThemeLoadResult {
+ val errors = mutableListOf()
+ val themeDir = Paths.get(themeDirPath).normalize().toFile()
+ if (!themeDir.exists() || !themeDir.isDirectory) {
+ return ThemeLoadResult.Failure(listOf(ThemeLoadError(
+ code = "THEME_DIR_NOT_FOUND",
+ message = "Theme directory not found: ${themeDirPath}",
+ source = null,
+ )))
+ }
+
+ val manifestEntry = readManifestEntry(themeDir, errors)
+ val themeXmlPath = resolvePath(themeDir, manifestEntry?.themePath ?: "theme.xml")
+ val includeResolver = IncludeResolver(themeDir.absolutePath)
+ val parsed = includeResolver.parseWithIncludes(themeXmlPath.absolutePath)
+ errors += parsed.errors
+ val root = parsed.root
+ if (root == null) {
+ return ThemeLoadResult.Failure(errors.ifEmpty { listOf(ThemeLoadError(
+ code = "THEME_XML_MISSING",
+ message = "Failed to parse theme XML at ${themeXmlPath}",
+ source = SourceLoc(themeXmlPath.absolutePath),
+ )) })
+ }
+
+ // Gather variables and breakpoints: external (from manifest entry) + inline + any nodes.
+ val variables = LinkedHashMap() // maintain insertion order; last writer wins on put
+ val breakpoints = mutableListOf()
+
+ // 1) External variables from manifest entry
+ manifestEntry?.variablesPath?.let { varRel ->
+ val varFile = resolvePath(themeDir, varRel)
+ if (varFile.exists()) {
+ val parsed = parseVariablesFile(varFile, errors)
+ variables.putAll(parsed.variables)
+ breakpoints.addAll(parsed.breakpoints)
+ } else {
+ errors += ThemeLoadError(
+ code = "VARIABLES_FILE_NOT_FOUND",
+ message = "variables.xml not found: ${varFile}",
+ source = SourceLoc(varFile.absolutePath),
+ )
+ }
+ }
+
+ // 2) Inline variables and variables ref inside theme tree
+ collectVariablesFromTree(root, themeDir, variables, breakpoints, errors)
+
+ val tree = ThemeTree(
+ rootDir = themeDir.absolutePath,
+ manifestEntry = manifestEntry,
+ themeXml = root,
+ variables = variables,
+ breakpoints = breakpoints,
+ )
+ return if (errors.isEmpty()) ThemeLoadResult.Success(tree) else ThemeLoadResult.Failure(errors)
+ }
+
+ // --- Manifest parsing ---
+
+ private fun readManifestEntry(themeDir: File, errors: MutableList): ManifestEntry? {
+ val manifestFile = File(themeDir, "manifest.xml")
+ if (!manifestFile.exists()) return null
+ val factory = SAXParserFactory.newInstance().apply {
+ isNamespaceAware = false
+ isValidating = false
+ try { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) } catch (_: Exception) {}
+ }
+ var result: ManifestEntry? = null
+ try {
+ val parser = factory.newSAXParser()
+ var inManifest = false
+ parser.parse(InputSource(manifestFile.inputStream()), object : DefaultHandler() {
+ private var loc: Locator? = null
+ override fun setDocumentLocator(locator: Locator?) { this.loc = locator }
+ override fun startElement(uri: String?, localName: String?, qName: String, attributes: Attributes) {
+ when (qName) {
+ "manifest" -> inManifest = true
+ "theme" -> if (inManifest) {
+ val source = attributes.getValue("source")
+ val vars = attributes.getValue("variables")
+ val src = SourceLoc(manifestFile.absolutePath, loc?.lineNumber, loc?.columnNumber)
+ if (source != null) {
+ result = ManifestEntry(themePath = source, variablesPath = vars, source = src)
+ } else {
+ errors += ThemeLoadError(
+ code = "MANIFEST_THEME_MISSING_SOURCE",
+ message = " in manifest.xml is missing required 'source' attribute",
+ source = src,
+ )
+ }
+ }
+ }
+ }
+ })
+ } catch (e: Exception) {
+ errors += ThemeLoadError(
+ code = "MANIFEST_PARSE_ERROR",
+ message = "Failed to parse manifest.xml: ${e.message}",
+ source = SourceLoc(manifestFile.absolutePath),
+ )
+ }
+ return result
+ }
+
+ // --- Variables parsing ---
+
+ /** Result of parsing a variables file, containing both base variables and breakpoints. */
+ private data class VariablesParseResult(
+ val variables: Map,
+ val breakpoints: List
+ )
+
+ private fun collectVariablesFromTree(
+ root: XmlNode,
+ themeDir: File,
+ outVars: MutableMap,
+ outBreakpoints: MutableList,
+ errors: MutableList
+ ) {
+ fun traverse(node: XmlNode) {
+ if (node.name.equals("variables", ignoreCase = false)) {
+ // Load external referenced variables first (so inline can override if needed)
+ val ref = node.attributes["ref"]
+ if (!ref.isNullOrBlank()) {
+ val base = node.source?.filePath?.let { File(it).parentFile ?: themeDir } ?: themeDir
+ val refFile = if (ref.startsWith("/")) File(themeDir, ref.removePrefix("/")).canonicalFile else File(base, ref).canonicalFile
+ if (refFile.exists()) {
+ val parsed = parseVariablesFile(refFile, errors)
+ outVars.putAll(parsed.variables)
+ outBreakpoints.addAll(parsed.breakpoints)
+ } else {
+ errors += ThemeLoadError(
+ code = "VARIABLES_REF_NOT_FOUND",
+ message = "Referenced variables file not found: ${ref}",
+ source = node.source,
+ )
+ }
+ }
+ // Inline variable definitions (top-level vars, not inside breakpoints)
+ node.children.filter { it.name == "var" }.forEach { vNode ->
+ val name = vNode.attributes["name"]
+ val value = vNode.attributes["value"] ?: vNode.text
+ if (!name.isNullOrBlank() && value != null) {
+ outVars[name] = value
+ } else {
+ errors += ThemeLoadError(
+ code = "VAR_BAD_DEF",
+ message = " must have name and value",
+ source = vNode.source,
+ )
+ }
+ }
+ // Inline breakpoint definitions
+ node.children.filter { it.name == "breakpoint" }.forEach { bpNode ->
+ val bp = parseBreakpointNode(bpNode, errors)
+ if (bp != null) {
+ outBreakpoints.add(bp)
+ }
+ }
+ }
+ node.children.forEach { traverse(it) }
+ }
+ traverse(root)
+ }
+
+ private fun parseVariablesFile(file: File, errors: MutableList): VariablesParseResult {
+ val variables = LinkedHashMap()
+ val breakpoints = mutableListOf()
+ val factory = SAXParserFactory.newInstance().apply {
+ isNamespaceAware = false
+ isValidating = false
+ try { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) } catch (_: Exception) {}
+ }
+ try {
+ val parser = factory.newSAXParser()
+ var inVariables = false
+ var inBreakpoint = false
+ var currentBreakpointOrientation: Orientation? = null
+ var currentBreakpointMinWidth: Int? = null
+ var currentBreakpointMaxWidth: Int? = null
+ var currentBreakpointVars = LinkedHashMap()
+
+ parser.parse(InputSource(file.inputStream()), object : DefaultHandler() {
+ private var loc: Locator? = null
+ override fun setDocumentLocator(locator: Locator?) { this.loc = locator }
+
+ override fun startElement(uri: String?, localName: String?, qName: String, attributes: Attributes) {
+ when (qName) {
+ "variables" -> inVariables = true
+ "breakpoint" -> if (inVariables) {
+ inBreakpoint = true
+ currentBreakpointOrientation = Orientation.fromString(attributes.getValue("orientation"))
+ currentBreakpointMinWidth = attributes.getValue("minWidth")?.toIntOrNull()
+ currentBreakpointMaxWidth = attributes.getValue("maxWidth")?.toIntOrNull()
+ currentBreakpointVars = LinkedHashMap()
+ }
+ "var" -> if (inVariables) {
+ val name = attributes.getValue("name")
+ val value = attributes.getValue("value")
+ if (!name.isNullOrBlank() && value != null) {
+ if (inBreakpoint) {
+ currentBreakpointVars[name] = value
+ } else {
+ variables[name] = value
+ }
+ }
+ }
+ }
+ }
+
+ override fun endElement(uri: String?, localName: String?, qName: String) {
+ when (qName) {
+ "breakpoint" -> if (inBreakpoint) {
+ // Only add if we have variables and at least one condition
+ if (currentBreakpointVars.isNotEmpty() &&
+ (currentBreakpointOrientation != null || currentBreakpointMinWidth != null || currentBreakpointMaxWidth != null)) {
+ breakpoints.add(Breakpoint(
+ orientation = currentBreakpointOrientation,
+ minWidth = currentBreakpointMinWidth,
+ maxWidth = currentBreakpointMaxWidth,
+ variables = currentBreakpointVars.toMap()
+ ))
+ }
+ inBreakpoint = false
+ currentBreakpointOrientation = null
+ currentBreakpointMinWidth = null
+ currentBreakpointMaxWidth = null
+ currentBreakpointVars = LinkedHashMap()
+ }
+ "variables" -> inVariables = false
+ }
+ }
+ })
+ } catch (e: Exception) {
+ errors += ThemeLoadError(
+ code = "VARIABLES_PARSE_ERROR",
+ message = "Failed to parse variables file '${file.name}': ${e.message}",
+ source = SourceLoc(file.absolutePath),
+ )
+ }
+ return VariablesParseResult(variables, breakpoints)
+ }
+
+ /** Parse a breakpoint XmlNode from inline theme.xml. */
+ private fun parseBreakpointNode(node: XmlNode, errors: MutableList): Breakpoint? {
+ val orientation = Orientation.fromString(node.attributes["orientation"])
+ val minWidth = node.attributes["minWidth"]?.toIntOrNull()
+ val maxWidth = node.attributes["maxWidth"]?.toIntOrNull()
+
+ // Must have at least one condition
+ if (orientation == null && minWidth == null && maxWidth == null) {
+ errors += ThemeLoadError(
+ code = "BREAKPOINT_NO_CONDITION",
+ message = " must have 'orientation', 'minWidth', or 'maxWidth' attribute",
+ source = node.source,
+ )
+ return null
+ }
+
+ val vars = LinkedHashMap()
+ node.children.filter { it.name == "var" }.forEach { vNode ->
+ val name = vNode.attributes["name"]
+ val value = vNode.attributes["value"] ?: vNode.text
+ if (!name.isNullOrBlank() && value != null) {
+ vars[name] = value
+ }
+ }
+
+ if (vars.isEmpty()) {
+ // Empty breakpoint - not an error, just skip it
+ return null
+ }
+
+ return Breakpoint(
+ orientation = orientation,
+ minWidth = minWidth,
+ maxWidth = maxWidth,
+ variables = vars
+ )
+ }
+
+ // --- Utils ---
+
+ private fun resolvePath(baseDir: File, raw: String): File {
+ return if (raw.startsWith("/")) {
+ // Leading slash means theme root
+ File(baseDir, ".") // normalize base
+ File(baseDir.absolutePath).parentFile?.let { themeRootParent ->
+ // But we actually have themeDir as base; we want themeDir itself.
+ // Simpler: compute using Paths
+ val root = baseDir.toPath()
+ val abs = root.resolve(raw.removePrefix("/")).normalize()
+ abs.toFile()
+ } ?: File(baseDir, raw.removePrefix("/"))
+ } else {
+ File(baseDir, raw).canonicalFile
+ }
+ }
+}
diff --git a/app/src/main/java/app/gamenative/theme/io/ThemeStringResolver.kt b/app/src/main/java/app/gamenative/theme/io/ThemeStringResolver.kt
new file mode 100644
index 000000000..233ea12a0
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/io/ThemeStringResolver.kt
@@ -0,0 +1,183 @@
+package app.gamenative.theme.io
+
+import android.content.Context
+import android.content.res.AssetManager
+import android.util.Log
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.InputStream
+import java.util.Locale
+
+/**
+ * Resolves `@string/key` references from theme-specific string files and app resources.
+ *
+ * Priority order:
+ * 1. Theme strings for current device locale (e.g., `locales/da.xml`)
+ * 2. Theme strings for fallback (e.g., `locales/default.xml` or `locales/en.xml`)
+ * 3. App string resources (`R.string.*`)
+ * 4. Return the key name if nothing found
+ */
+class ThemeStringResolver(
+ private val context: Context,
+ private val assetManager: AssetManager,
+) {
+ companion object {
+ private const val TAG = "ThemeStringResolver"
+ private const val STRING_PREFIX = "@string/"
+ }
+
+ // Cache: themePath -> (locale -> strings map)
+ private val themeStringCache = mutableMapOf>>()
+
+ /**
+ * Check if a string value is a string resource reference.
+ */
+ fun isStringReference(value: String): Boolean = value.startsWith(STRING_PREFIX)
+
+ /**
+ * Resolve a string value. If it starts with @string/, look up the resource.
+ * Otherwise return the value as-is.
+ */
+ fun resolve(value: String, themePath: String?): String {
+ if (!isStringReference(value)) return value
+
+ val key = value.removePrefix(STRING_PREFIX)
+ return resolveKey(key, themePath)
+ }
+
+ /**
+ * Resolve a string key from theme strings or app resources.
+ */
+ fun resolveKey(key: String, themePath: String?): String {
+ // 1. Try theme strings first
+ if (themePath != null) {
+ val themeString = getThemeString(key, themePath)
+ if (themeString != null) return themeString
+ }
+
+ // 2. Try app string resources
+ val appString = getAppString(key)
+ if (appString != null) return appString
+
+ // 3. Return key as fallback
+ Log.w(TAG, "String not found: $key")
+ return key
+ }
+
+ /**
+ * Get a string from theme's strings folder.
+ */
+ private fun getThemeString(key: String, themePath: String): String? {
+ val strings = loadThemeStrings(themePath)
+ val locale = Locale.getDefault().language // e.g., "da", "en", "de"
+
+ // Try current locale first
+ strings[locale]?.get(key)?.let { return it }
+
+ // Try default.xml or en.xml as fallback
+ strings["default"]?.get(key)?.let { return it }
+ strings["en"]?.get(key)?.let { return it }
+
+ return null
+ }
+
+ /**
+ * Get a string from app resources by name.
+ */
+ private fun getAppString(key: String): String? {
+ return try {
+ val resId = context.resources.getIdentifier(key, "string", context.packageName)
+ if (resId != 0) context.getString(resId) else null
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to get app string: $key", e)
+ null
+ }
+ }
+
+ /**
+ * Load all string files from a theme's locales/ folder.
+ * Returns a map of locale code -> (key -> value) map.
+ */
+ private fun loadThemeStrings(themePath: String): Map> {
+ // Return cached if available
+ themeStringCache[themePath]?.let { return it }
+
+ val result = mutableMapOf>()
+ val localesPath = "$themePath/locales"
+
+ try {
+ val files = assetManager.list(localesPath) ?: emptyArray()
+ for (file in files) {
+ if (file.endsWith(".xml")) {
+ val locale = file.removeSuffix(".xml") // "da", "en", "default"
+ val strings = parseStringFile("$localesPath/$file")
+ if (strings.isNotEmpty()) {
+ result[locale] = strings
+ }
+ }
+ }
+ } catch (e: Exception) {
+ // No locales folder or error reading - that's fine
+ Log.d(TAG, "No locales folder for theme: $themePath")
+ }
+
+ themeStringCache[themePath] = result
+ return result
+ }
+
+ /**
+ * Parse a string XML file.
+ * Format: Value
+ */
+ private fun parseStringFile(path: String): Map {
+ val result = mutableMapOf()
+
+ try {
+ val inputStream: InputStream = assetManager.open(path)
+ val factory = XmlPullParserFactory.newInstance()
+ val parser = factory.newPullParser()
+ parser.setInput(inputStream, "UTF-8")
+
+ var eventType = parser.eventType
+ var currentName: String? = null
+
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ when (eventType) {
+ XmlPullParser.START_TAG -> {
+ if (parser.name == "string") {
+ currentName = parser.getAttributeValue(null, "name")
+ }
+ }
+ XmlPullParser.TEXT -> {
+ if (currentName != null) {
+ result[currentName] = parser.text
+ }
+ }
+ XmlPullParser.END_TAG -> {
+ if (parser.name == "string") {
+ currentName = null
+ }
+ }
+ }
+ eventType = parser.next()
+ }
+ inputStream.close()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error parsing string file: $path", e)
+ }
+
+ return result
+ }
+
+ /**
+ * Clear the cache for a specific theme or all themes.
+ */
+ fun clearCache(themePath: String? = null) {
+ if (themePath != null) {
+ themeStringCache.remove(themePath)
+ } else {
+ themeStringCache.clear()
+ }
+ }
+}
+
diff --git a/app/src/main/java/app/gamenative/theme/io/ThemeXmlMapper.kt b/app/src/main/java/app/gamenative/theme/io/ThemeXmlMapper.kt
new file mode 100644
index 000000000..7c051050d
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/io/ThemeXmlMapper.kt
@@ -0,0 +1,1554 @@
+package app.gamenative.theme.io
+
+import app.gamenative.theme.model.*
+import app.gamenative.theme.runtime.VariableResolver
+import java.io.File
+
+/**
+ * Maps a merged ThemeTree (XmlNode-based) produced by ThemeLoader into the runtime ThemeDefinition model
+ * used by the rendering engine. This does not perform validation — call ThemeValidator first.
+ */
+object ThemeXmlMapper {
+
+ /**
+ * Convert the merged [ThemeTree] into a [ThemeDefinition].
+ *
+ * @param tree The loaded theme tree
+ * @param resolvedVariables Optional pre-resolved variables (with breakpoints applied).
+ * If null, uses base variables from tree without breakpoint overrides.
+ */
+ fun map(tree: ThemeTree, resolvedVariables: Map? = null): ThemeDefinition {
+ val root = tree.themeXml
+
+ // Use resolved variables if provided, otherwise use base variables
+ val effectiveVariables = resolvedVariables ?: tree.variables
+
+ // Create a tree view with the effective variables for parsing
+ val effectiveTree = if (resolvedVariables != null) {
+ tree.copy(variables = effectiveVariables)
+ } else {
+ tree
+ }
+
+ // Variables: best-effort mapping from loader map -> Variable entries (typed as STRING by default)
+ val variables = effectiveVariables.map { (k, v) ->
+ Variable(id = k, type = ValueType.STRING, defaultValue = v)
+ }
+
+ // Parse section (new format) for pre-defined cards and fixed containers
+ val elementsNode = root.children.firstOrNull { it.name.equals("elements", ignoreCase = true) }
+ val elementCards = elementsNode?.let { parseCardsFromContainer(it, effectiveTree) } ?: emptyList()
+ val elementFixedContainers = elementsNode?.let { parseFixedContainersFromContainer(it, effectiveTree) } ?: emptyList()
+
+ // Build lookup maps for element references
+ val fixedContainerLookup = elementFixedContainers.associateBy { it.id }
+
+ // Parse cards from both and legacy section
+ val legacyCards = parseCards(root, effectiveTree)
+
+ // Parse layout and extract layout elements and inline cards in declaration order
+ val layoutResult = parseLayoutWithFixedContainers(root, effectiveTree, fixedContainerLookup)
+ val inlineCards = layoutResult.inlineCards
+
+ // Combine all cards: elements + legacy + inline (inline cards take precedence for same ID)
+ val cards = (elementCards + legacyCards + inlineCards).distinctBy { it.id }
+
+ // Determine which layout elements to use:
+ // - If layout contains fixed/element/content tags, use those (new format)
+ // - Otherwise fall back to root-level tags (backwards compat) + just the content node
+ val layoutElements = if (layoutResult.layoutElements.any { it is LayoutElement.Fixed }) {
+ layoutResult.layoutElements
+ } else {
+ // Legacy mode: parse root-level fixed containers and combine with content node
+ val legacyFixed = parseFixedContainers(root, effectiveTree)
+ val contentElements = layoutResult.layoutElements.filterIsInstance()
+ // Put fixed containers at the end (legacy behavior: UI elements on top)
+ contentElements + legacyFixed.mapIndexed { index, container ->
+ LayoutElement.Fixed(
+ container = container,
+ zIndex = null,
+ declarationOrder = contentElements.size + index,
+ )
+ }
+ }
+
+ val manifest = buildManifest(tree)
+
+ return ThemeDefinition(
+ manifest = manifest,
+ variables = variables,
+ breakpoints = tree.breakpoints,
+ cards = cards,
+ layoutElements = layoutElements,
+ )
+ }
+
+ // region Manifest
+ private fun buildManifest(tree: ThemeTree): Manifest {
+ // Try to infer id and version from manifest.xml filename and contents are already validated elsewhere.
+ val themeDir = File(tree.rootDir)
+ val id = themeDir.name.ifBlank { tree.manifestEntry?.themePath ?: "unknown" }
+ // We don't have parsed manifest fields here; use safe defaults (engine is authoritative)
+ return Manifest(
+ id = id,
+ version = "1.0.0",
+ engineVersion = ThemeEngine.ENGINE_VERSION,
+ minAppVersion = "0.0.0",
+ maxAppVersion = null,
+ )
+ }
+ // endregion
+
+ // region Cards
+ private fun parseCards(root: XmlNode, tree: ThemeTree): List {
+ // Support both new / and legacy / for backward compatibility
+ val cardsRoot = root.children.firstOrNull { it.name.equals("cards", ignoreCase = true) }
+ ?: root.children.firstOrNull { it.name.equals("templates", ignoreCase = true) }
+ ?: return emptyList()
+
+ return parseCardsFromContainer(cardsRoot, tree)
+ }
+
+ /** Parse card definitions from a container node (either or ). */
+ private fun parseCardsFromContainer(container: XmlNode, tree: ThemeTree): List {
+ // Support both "card" and "template" tag names for backwards compatibility
+ return container.children.filter {
+ it.name.equals("card", ignoreCase = true) || it.name.equals("template", ignoreCase = true)
+ }.map { n ->
+ val id = n.attributes["id"] ?: "card_${System.nanoTime()}"
+ // Default to 100% width/height if not specified
+ val width = resolveDimensionWidth(n, "width", tree) ?: Dimension.RelW(1f)
+ val height = resolveDimensionHeight(n, "height", tree) ?: Dimension.RelH(1f)
+ val layers = n.children.mapIndexedNotNull { index, child ->
+ parseLayer(child, tree, index)
+ }
+ Card(
+ id = id,
+ canvas = DimSize(width, height),
+ layers = layers,
+ // states/transitions future work — kept empty for now
+ )
+ }
+ }
+
+ // region Fixed Containers
+
+ /** Parse root-level containers (backwards compatibility). */
+ private fun parseFixedContainers(root: XmlNode, tree: ThemeTree): List {
+ return root.children.filter { it.name.equals("fixed", ignoreCase = true) }.map { containerNode ->
+ parseFixedContainerNode(containerNode, tree)
+ }
+ }
+
+ /** Parse containers from a container node (e.g., ). */
+ private fun parseFixedContainersFromContainer(container: XmlNode, tree: ThemeTree): List {
+ return container.children.filter { it.name.equals("fixed", ignoreCase = true) }.map { containerNode ->
+ parseFixedContainerNode(containerNode, tree)
+ }
+ }
+
+ /** Parse a single container node into a FixedContainer. */
+ private fun parseFixedContainerNode(containerNode: XmlNode, tree: ThemeTree): FixedContainer {
+ val id = containerNode.attributes["id"] ?: "default"
+ val elements = containerNode.children.mapIndexedNotNull { index, child ->
+ parseFixedElement(child, tree, index)
+ }
+ val backgroundColor = resolveColorAttr(containerNode, "backgroundColor", tree)
+ val height = resolveFloatOrNull(containerNode, "height", tree)
+ val visibility = Visibility.fromString(containerNode.attributes["visibility"])
+ val padding = resolveStringAttr(containerNode, "padding", tree)
+ val cornerRadius = resolveFloat(containerNode, "cornerRadius", 0f, tree)
+ return FixedContainer(
+ id = id,
+ elements = elements,
+ backgroundColor = backgroundColor,
+ height = height,
+ visibility = visibility,
+ padding = padding,
+ cornerRadius = cornerRadius,
+ )
+ }
+
+ /**
+ * Helper class holding common base properties shared by all fixed element types.
+ */
+ private data class FixedElementBase(
+ val position: DimOffset,
+ val anchor: Anchor,
+ val visibility: Visibility,
+ val zIndex: Float,
+ val declarationOrder: Int,
+ val highlightColor: Int?,
+ val highlightOpacity: Float,
+ val highlightBorderWidth: Float,
+ val highlightTransitionSpeed: Int,
+ val navigationId: String?,
+ val navigateUp: String?,
+ val navigateDown: String?,
+ val navigateLeft: String?,
+ val navigateRight: String?,
+ )
+
+ /** Extract common base properties from an XML node for fixed elements. */
+ private fun parseFixedElementBase(n: XmlNode, tree: ThemeTree, declarationOrder: Int) = FixedElementBase(
+ position = DimOffset(pxResolved(n, "x", tree), pxResolved(n, "y", tree)),
+ anchor = Anchor.fromString(n.attributes["anchor"]),
+ visibility = Visibility.fromString(n.attributes["visibility"]),
+ zIndex = resolveFloat(n, "zIndex", default = 0f, tree),
+ declarationOrder = declarationOrder,
+ highlightColor = resolveColorAttr(n, "highlightColor", tree),
+ highlightOpacity = resolveFloat(n, "highlightOpacity", 0.8f, tree),
+ highlightBorderWidth = resolveFloat(n, "highlightBorderWidth", 2f, tree),
+ highlightTransitionSpeed = resolveInt(n, "highlightTransitionSpeed", tree) ?: 200,
+ navigationId = n.attributes["navigationId"],
+ navigateUp = n.attributes["navigateUp"],
+ navigateDown = n.attributes["navigateDown"],
+ navigateLeft = n.attributes["navigateLeft"],
+ navigateRight = n.attributes["navigateRight"],
+ )
+
+ private fun parseFixedElement(n: XmlNode, tree: ThemeTree, declarationOrder: Int): FixedElement? {
+ val base = parseFixedElementBase(n, tree, declarationOrder)
+
+ return when (n.name.lowercase()) {
+ "header" -> FixedElement.Header(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ textColor = resolveColorAttr(n, "textColor", tree) ?: 0xFFFFFFFF.toInt(),
+ showAppName = n.attributes["showAppName"]?.toBooleanStrictOrNull()
+ ?: n.children.any { it.name.equals("appName", true) && it.attributes["visible"]?.toBooleanStrictOrNull() != false }
+ ?: true,
+ showThemeName = n.attributes["showThemeName"]?.toBooleanStrictOrNull()
+ ?: n.children.any { it.name.equals("themeName", true) && it.attributes["visible"]?.toBooleanStrictOrNull() != false }
+ ?: true,
+ showGameCount = n.attributes["showGameCount"]?.toBooleanStrictOrNull()
+ ?: n.children.any { it.name.equals("gameCount", true) && it.attributes["visible"]?.toBooleanStrictOrNull() != false }
+ ?: true,
+ size = sizeResolved(n, tree),
+ backgroundColor = resolveColorAttr(n, "backgroundColor", tree),
+ cornerRadius = resolveFloat(n, "cornerRadius", 0f, tree),
+ padding = resolveFloat(n, "padding", 8f, tree),
+ textSize = resolveFloat(n, "textSize", 14f, tree),
+ fontWeight = n.attributes["fontWeight"] ?: "bold",
+ textShadowColor = resolveColorAttr(n, "textShadowColor", tree),
+ textShadowRadius = resolveFloat(n, "textShadowRadius", 0f, tree),
+ textShadowOffsetX = resolveFloat(n, "textShadowOffsetX", 0f, tree),
+ textShadowOffsetY = resolveFloat(n, "textShadowOffsetY", 0f, tree),
+ )
+ "searchbar" -> FixedElement.SearchBar(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = sizeResolved(n, tree) ?: DimSize(Dimension.Px(400f), Dimension.Px(48f)),
+ backgroundColor = resolveColorAttr(n, "backgroundColor", tree),
+ textColor = resolveColorAttr(n, "textColor", tree),
+ borderRadius = resolveFloat(n, "borderRadius", 8f, tree),
+ collapsible = n.attributes["collapsible"]?.toBooleanStrictOrNull() ?: false,
+ expandDirection = n.attributes["expandDirection"] ?: "left",
+ textShadowColor = resolveColorAttr(n, "textShadowColor", tree),
+ textShadowRadius = resolveFloat(n, "textShadowRadius", 0f, tree),
+ textShadowOffsetX = resolveFloat(n, "textShadowOffsetX", 0f, tree),
+ textShadowOffsetY = resolveFloat(n, "textShadowOffsetY", 0f, tree),
+ )
+ "profilebutton" -> FixedElement.ProfileButton(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = resolveFloat(n, "size", 48f, tree),
+ iconSize = resolveFloat(n, "iconSize", 24f, tree),
+ padding = resolveFloat(n, "padding", 8f, tree),
+ backgroundColor = resolveColorAttr(n, "backgroundColor", tree),
+ cornerRadius = resolveFloat(n, "cornerRadius", 12f, tree),
+ )
+ "filterbutton" -> FixedElement.FilterButton(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ expanded = n.attributes["expanded"]?.toBooleanStrictOrNull() ?: true,
+ size = resolveFloat(n, "size", 56f, tree),
+ iconSize = resolveFloat(n, "iconSize", 24f, tree),
+ backgroundColor = resolveColorAttr(n, "backgroundColor", tree),
+ iconColor = resolveColorAttr(n, "iconColor", tree),
+ cornerRadius = resolveFloat(n, "cornerRadius", 16f, tree),
+ textShadowColor = resolveColorAttr(n, "textShadowColor", tree),
+ textShadowRadius = resolveFloat(n, "textShadowRadius", 0f, tree),
+ textShadowOffsetX = resolveFloat(n, "textShadowOffsetX", 0f, tree),
+ textShadowOffsetY = resolveFloat(n, "textShadowOffsetY", 0f, tree),
+ padding = n.attributes["padding"],
+ )
+ "addbutton" -> FixedElement.AddButton(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ expanded = n.attributes["expanded"]?.toBooleanStrictOrNull() ?: false,
+ size = resolveFloat(n, "size", 56f, tree),
+ iconSize = resolveFloat(n, "iconSize", 24f, tree),
+ backgroundColor = resolveColorAttr(n, "backgroundColor", tree),
+ iconColor = resolveColorAttr(n, "iconColor", tree),
+ cornerRadius = resolveFloat(n, "cornerRadius", 16f, tree),
+ textShadowColor = resolveColorAttr(n, "textShadowColor", tree),
+ textShadowRadius = resolveFloat(n, "textShadowRadius", 0f, tree),
+ textShadowOffsetX = resolveFloat(n, "textShadowOffsetX", 0f, tree),
+ textShadowOffsetY = resolveFloat(n, "textShadowOffsetY", 0f, tree),
+ padding = n.attributes["padding"],
+ )
+ "image" -> FixedElement.Image(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = sizeResolved(n, tree) ?: DimSize(Dimension.Px(100f), Dimension.Px(100f)),
+ src = resolveStringAttr(n, "src", tree) ?: "",
+ scaleType = n.attributes["scaleType"] ?: "cover",
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ opacity = resolveFloat(n, "opacity", 1f, tree),
+ )
+ "video" -> FixedElement.Video(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = sizeResolved(n, tree) ?: DimSize(Dimension.Px(200f), Dimension.Px(150f)),
+ src = resolveStringAttr(n, "src", tree) ?: "",
+ poster = resolveStringAttr(n, "poster", tree),
+ autoplay = n.attributes["autoplay"]?.toBooleanStrictOrNull() ?: false,
+ loop = n.attributes["loop"]?.toBooleanStrictOrNull() ?: true,
+ muted = n.attributes["muted"]?.toBooleanStrictOrNull() ?: true,
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ opacity = resolveFloat(n, "opacity", 1f, tree),
+ )
+ "rect" -> FixedElement.Rect(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = sizeResolved(n, tree) ?: DimSize(Dimension.Px(100f), Dimension.Px(100f)),
+ color = resolveColorAttr(n, "color", tree) ?: 0x00000000,
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ borderWidth = resolveFloat(n, "borderWidth", 0f, tree),
+ borderColor = resolveColorAttr(n, "borderColor", tree) ?: 0x00000000,
+ gradientStart = resolveColorAttr(n, "gradientStart", tree),
+ gradientEnd = resolveColorAttr(n, "gradientEnd", tree),
+ gradientAngle = resolveFloat(n, "gradientAngle", 0f, tree),
+ opacity = resolveFloat(n, "opacity", 1f, tree),
+ )
+ "text" -> FixedElement.Text(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = sizeResolved(n, tree),
+ text = resolveStringAttr(n, "text", tree) ?: "",
+ color = resolveColorAttr(n, "color", tree) ?: 0xFFFFFFFF.toInt(),
+ textSize = resolveFloat(n, "textSize", 14f, tree),
+ maxLines = resolveInt(n, "maxLines", tree),
+ textAlign = n.attributes["textAlign"] ?: "left",
+ fontWeight = n.attributes["fontWeight"] ?: "normal",
+ fontStyle = n.attributes["fontStyle"] ?: "normal",
+ overflow = n.attributes["overflow"] ?: "ellipsis",
+ opacity = resolveFloat(n, "opacity", 1f, tree),
+ )
+ "shadow" -> FixedElement.Shadow(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = sizeResolved(n, tree) ?: DimSize(Dimension.Px(100f), Dimension.Px(100f)),
+ radius = resolveFloat(n, "radius", 8f, tree),
+ color = resolveColorAttr(n, "color", tree) ?: 0x66000000,
+ offsetX = resolveFloat(n, "offsetX", 0f, tree),
+ offsetY = resolveFloat(n, "offsetY", 4f, tree),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ opacity = resolveFloat(n, "opacity", 1f, tree),
+ )
+ "border" -> FixedElement.Border(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = sizeResolved(n, tree) ?: DimSize(Dimension.Px(100f), Dimension.Px(100f)),
+ strokeWidth = resolveFloat(n, "strokeWidth", 1f, tree),
+ color = resolveColorAttr(n, "color", tree) ?: 0xFFFFFFFF.toInt(),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ opacity = resolveFloat(n, "opacity", 1f, tree),
+ )
+ "backdrop" -> FixedElement.Backdrop(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ size = sizeResolved(n, tree) ?: DimSize(Dimension.Px(100f), Dimension.Px(100f)),
+ blurRadius = resolveFloat(n, "blurRadius", 16f, tree),
+ tintColor = resolveColorAttr(n, "tintColor", tree),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ opacity = resolveFloat(n, "opacity", 1f, tree),
+ )
+ "systemtime" -> FixedElement.SystemTime(
+ position = base.position,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ highlightColor = base.highlightColor,
+ highlightOpacity = base.highlightOpacity,
+ highlightBorderWidth = base.highlightBorderWidth,
+ highlightTransitionSpeed = base.highlightTransitionSpeed,
+ navigationId = base.navigationId,
+ navigateUp = base.navigateUp,
+ navigateDown = base.navigateDown,
+ navigateLeft = base.navigateLeft,
+ navigateRight = base.navigateRight,
+ textSize = resolveFloat(n, "textSize", 16f, tree),
+ textColor = resolveColorAttr(n, "textColor", tree),
+ fontWeight = n.attributes["fontWeight"] ?: "normal",
+ use24Hour = n.attributes["use24Hour"]?.toBooleanStrictOrNull() ?: false,
+ )
+ else -> null
+ }
+ }
+
+ // endregion
+
+ /**
+ * Helper class holding common base properties shared by all layer types.
+ */
+ private data class LayerBase(
+ val id: String?,
+ val position: DimOffset,
+ val size: DimSize?,
+ val opacity: FloatOrBinding?,
+ val anchor: Anchor,
+ val visibility: Visibility,
+ val zIndex: Float,
+ val declarationOrder: Int,
+ val focusOnly: Boolean,
+ val focusTransitionSpeed: Int,
+ val visibleWhen: String?,
+ )
+
+ /** Extract common base properties from an XML node. */
+ private fun parseLayerBase(n: XmlNode, tree: ThemeTree, declarationOrder: Int) = LayerBase(
+ id = n.attributes["id"],
+ position = DimOffset(pxResolved(n, "x", tree), pxResolved(n, "y", tree)),
+ size = sizeResolved(n, tree),
+ opacity = floatBindingResolved(n.attributes["opacity"], tree),
+ anchor = Anchor.fromString(n.attributes["anchor"]),
+ visibility = Visibility.fromString(n.attributes["visibility"]),
+ zIndex = resolveFloat(n, "zIndex", default = 0f, tree),
+ declarationOrder = declarationOrder,
+ focusOnly = n.attributes["focusOnly"]?.toBooleanStrictOrNull() ?: false,
+ focusTransitionSpeed = resolveInt(n, "focusTransitionSpeed", tree) ?: 150,
+ visibleWhen = n.attributes["visibleWhen"]?.let { extractBindingPath(it) },
+ )
+
+ private fun parseLayer(n: XmlNode, tree: ThemeTree, declarationOrder: Int): Layer? {
+ val base = parseLayerBase(n, tree, declarationOrder)
+
+ return when (n.name.lowercase()) {
+ "image" -> Layer.ImageLayer(
+ id = base.id,
+ position = base.position,
+ size = base.size,
+ opacity = base.opacity,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ focusOnly = base.focusOnly,
+ focusTransitionSpeed = base.focusTransitionSpeed,
+ visibleWhen = base.visibleWhen,
+ source = MediaSource.Image(
+ src = stringBinding(n.attributes["src"]) ?: StringOrBinding.Literal(""),
+ fallback = stringBinding(n.attributes["fallback"]),
+ ),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ tintColor = intBindingResolved(n.attributes["tint"], tree),
+ scaleType = n.attributes["scaleType"] ?: "cover",
+ )
+ "video" -> Layer.VideoLayer(
+ id = base.id,
+ position = base.position,
+ size = base.size,
+ opacity = base.opacity,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ focusOnly = base.focusOnly,
+ focusTransitionSpeed = base.focusTransitionSpeed,
+ visibleWhen = base.visibleWhen,
+ source = MediaSource.Video(
+ src = stringBinding(n.attributes["src"]) ?: StringOrBinding.Literal(""),
+ poster = stringBinding(n.attributes["poster"]),
+ autoplay = n.attributes["autoplay"]?.toBooleanStrictOrNull() ?: false,
+ loop = n.attributes["loop"]?.toBooleanStrictOrNull() ?: true,
+ muted = n.attributes["muted"]?.toBooleanStrictOrNull() ?: true,
+ preload = when (n.attributes["preload"]?.lowercase()) {
+ "none" -> VideoPreloadPolicy.NONE
+ "auto" -> VideoPreloadPolicy.AUTO
+ else -> VideoPreloadPolicy.METADATA
+ },
+ fallbackImage = stringBinding(n.attributes["fallbackImage"]),
+ ),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ )
+ // Support both "rect" (new) and "overlay" (legacy) for rectangle shapes
+ "rect", "overlay" -> Layer.RectLayer(
+ id = base.id,
+ position = base.position,
+ size = base.size,
+ opacity = base.opacity,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ focusOnly = base.focusOnly,
+ focusTransitionSpeed = base.focusTransitionSpeed,
+ visibleWhen = base.visibleWhen,
+ color = intBindingResolved(n.attributes["color"], tree) ?: IntOrBinding.Literal(0x88000000.toInt()),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ borderWidth = floatBindingResolved(n.attributes["borderWidth"], tree),
+ borderColor = intBindingResolved(n.attributes["borderColor"], tree),
+ borderGradient = n.attributes["borderGradient"]?.toBooleanStrictOrNull() ?: false,
+ gradientStart = intBindingResolved(n.attributes["gradientStart"], tree),
+ gradientEnd = intBindingResolved(n.attributes["gradientEnd"], tree),
+ gradientAngle = floatBindingResolved(n.attributes["gradientAngle"], tree),
+ )
+ "shadow" -> Layer.ShadowLayer(
+ id = base.id,
+ position = base.position,
+ size = base.size,
+ opacity = base.opacity,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ focusOnly = base.focusOnly,
+ focusTransitionSpeed = base.focusTransitionSpeed,
+ visibleWhen = base.visibleWhen,
+ radius = floatBindingResolved(n.attributes["radius"], tree) ?: FloatOrBinding.Literal(8f),
+ color = intBindingResolved(n.attributes["color"], tree) ?: IntOrBinding.Literal(0x80000000.toInt()),
+ offset = DimOffset(pxResolved(n, "dx", tree), pxResolved(n, "dy", tree)),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ )
+ "border" -> Layer.BorderLayer(
+ id = base.id,
+ position = base.position,
+ size = base.size,
+ opacity = base.opacity,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ focusOnly = base.focusOnly,
+ focusTransitionSpeed = base.focusTransitionSpeed,
+ visibleWhen = base.visibleWhen,
+ strokeWidth = floatBindingResolved(n.attributes["strokeWidth"], tree) ?: FloatOrBinding.Literal(2f),
+ color = intBindingResolved(n.attributes["color"], tree) ?: IntOrBinding.Literal(0xFFFFFFFF.toInt()),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ )
+ "text" -> Layer.TextLayer(
+ id = base.id,
+ position = base.position,
+ size = base.size,
+ opacity = base.opacity,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ focusOnly = base.focusOnly,
+ focusTransitionSpeed = base.focusTransitionSpeed,
+ visibleWhen = base.visibleWhen,
+ text = stringBinding(n.attributes["text"]) ?: StringOrBinding.Literal(""),
+ color = intBindingResolved(n.attributes["color"], tree) ?: IntOrBinding.Literal(0xFFFFFFFF.toInt()),
+ textSize = floatBindingResolved(n.attributes["textSize"], tree) ?: FloatOrBinding.Literal(18f),
+ maxLines = resolveInt(n, "maxLines", tree),
+ textAlign = n.attributes["textAlign"] ?: "left",
+ fontWeight = n.attributes["fontWeight"] ?: "normal",
+ fontStyle = n.attributes["fontStyle"] ?: "normal",
+ lineHeight = floatBindingResolved(n.attributes["lineHeight"], tree),
+ letterSpacing = floatBindingResolved(n.attributes["letterSpacing"], tree),
+ textDecoration = n.attributes["textDecoration"] ?: "none",
+ overflow = n.attributes["overflow"] ?: "ellipsis",
+ shadowColor = intBindingResolved(n.attributes["shadowColor"], tree),
+ shadowRadius = floatBindingResolved(n.attributes["shadowRadius"], tree),
+ shadowOffsetX = floatBindingResolved(n.attributes["shadowOffsetX"], tree),
+ shadowOffsetY = floatBindingResolved(n.attributes["shadowOffsetY"], tree),
+ )
+ "backdrop" -> Layer.BackdropLayer(
+ id = base.id,
+ position = base.position,
+ size = base.size,
+ opacity = base.opacity,
+ anchor = base.anchor,
+ visibility = base.visibility,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ focusOnly = base.focusOnly,
+ focusTransitionSpeed = base.focusTransitionSpeed,
+ visibleWhen = base.visibleWhen,
+ blurRadius = floatBindingResolved(n.attributes["blurRadius"], tree),
+ tintColor = intBindingResolved(n.attributes["tint"], tree),
+ )
+ "button" -> Layer.ButtonLayer(
+ id = base.id,
+ position = base.position,
+ size = base.size,
+ opacity = base.opacity,
+ anchor = base.anchor,
+ zIndex = base.zIndex,
+ declarationOrder = base.declarationOrder,
+ focusOnly = base.focusOnly,
+ focusTransitionSpeed = base.focusTransitionSpeed,
+ visibleWhen = base.visibleWhen,
+ visibility = base.visibility,
+ text = stringBinding(n.attributes["text"]) ?: StringOrBinding.Literal(""),
+ backgroundColor = intBindingResolved(n.attributes["backgroundColor"], tree) ?: IntOrBinding.Literal(0xFFE91E63.toInt()),
+ textColor = intBindingResolved(n.attributes["textColor"], tree) ?: IntOrBinding.Literal(0xFFFFFFFF.toInt()),
+ textSize = floatBindingResolved(n.attributes["textSize"], tree) ?: FloatOrBinding.Literal(14f),
+ cornerRadius = resolveStringAttr(n, "cornerRadius", tree),
+ borderWidth = floatBindingResolved(n.attributes["borderWidth"], tree),
+ borderColor = intBindingResolved(n.attributes["borderColor"], tree),
+ fontWeight = n.attributes["fontWeight"] ?: "normal",
+ padding = n.attributes["padding"],
+ )
+ else -> null
+ }
+ }
+ // endregion
+
+ // region Layout
+
+ /**
+ * Result of parsing the layout section, containing ordered layout elements
+ * and any inline card definitions found inside grid/carousel elements.
+ */
+ private data class LayoutParseResult(
+ val layoutElements: List,
+ val inlineCards: List,
+ )
+
+ /**
+ * Result of parsing a grid or carousel node, containing the layout node and
+ * any inline card definition found inside it.
+ */
+ private data class LayoutNodeWithCard(
+ val node: LayoutNode,
+ val inlineCard: Card?,
+ )
+
+ /**
+ * Parse layout and extract elements in declaration order.
+ * This supports the new format where and can appear inside ,
+ * and can be defined inline inside or .
+ *
+ * Elements are returned in declaration order with optional zIndex for explicit z-ordering.
+ *
+ * @return LayoutParseResult containing ordered layout elements and inline cards
+ */
+ private fun parseLayoutWithFixedContainers(
+ root: XmlNode,
+ tree: ThemeTree,
+ fixedContainerLookup: Map
+ ): LayoutParseResult {
+ val layoutRoot = root.children.firstOrNull { it.name.equals("layout", ignoreCase = true) }
+ ?: error(" element not found in theme.xml")
+
+ val layoutElements = mutableListOf()
+ val inlineCards = mutableListOf()
+ var declarationIndex = 0
+ var hasContentNode = false
+
+ // Process children in declaration order
+ for (child in layoutRoot.children) {
+ val zIndex = child.attributes["zIndex"]?.toIntOrNull()
+
+ when (child.name.lowercase()) {
+ // Inline container definition
+ "fixed" -> {
+ val container = parseFixedContainerNode(child, tree)
+ layoutElements.add(LayoutElement.Fixed(
+ container = container,
+ zIndex = zIndex,
+ declarationOrder = declarationIndex++,
+ ))
+ }
+ // Reference to pre-defined element:
+ "element" -> {
+ val ref = child.attributes["ref"]
+ if (ref != null) {
+ val referencedContainer = fixedContainerLookup[ref]
+ if (referencedContainer != null) {
+ layoutElements.add(LayoutElement.Fixed(
+ container = referencedContainer,
+ zIndex = zIndex,
+ declarationOrder = declarationIndex++,
+ ))
+ } else {
+ // Element reference not found - could log warning here
+ }
+ }
+ }
+ // Layout nodes (only one expected)
+ "canvas" -> {
+ if (!hasContentNode) {
+ hasContentNode = true
+ layoutElements.add(LayoutElement.Content(
+ node = parseCanvas(child, tree),
+ zIndex = zIndex,
+ declarationOrder = declarationIndex++,
+ ))
+ }
+ }
+ "grid" -> {
+ if (!hasContentNode) {
+ hasContentNode = true
+ val result = parseGridWithInlineCard(child, tree)
+ layoutElements.add(LayoutElement.Content(
+ node = result.node,
+ zIndex = zIndex,
+ declarationOrder = declarationIndex++,
+ ))
+ result.inlineCard?.let { inlineCards.add(it) }
+ }
+ }
+ "carousel" -> {
+ if (!hasContentNode) {
+ hasContentNode = true
+ val result = parseCarouselWithInlineCard(child, tree)
+ layoutElements.add(LayoutElement.Content(
+ node = result.node,
+ zIndex = zIndex,
+ declarationOrder = declarationIndex++,
+ ))
+ result.inlineCard?.let { inlineCards.add(it) }
+ }
+ }
+ // Ignore other nodes (like include which is handled by IncludeResolver)
+ }
+ }
+
+ // Ensure at least one content node was found
+ if (!hasContentNode) {
+ error(" must contain a layout node (canvas/grid/carousel)")
+ }
+
+ return LayoutParseResult(layoutElements, inlineCards)
+ }
+
+ /** Legacy layout parser (backwards compatibility). */
+ private fun parseLayout(root: XmlNode, tree: ThemeTree): LayoutNode {
+ val layoutRoot = root.children.firstOrNull { it.name.equals("layout", ignoreCase = true) }
+ ?: error(" element not found in theme.xml")
+ // Only one root layout child is supported (canvas/grid/carousel)
+ val child = layoutRoot.children.firstOrNull {
+ it.name.lowercase() in listOf("canvas", "grid", "carousel")
+ } ?: error(" must contain a root layout node (canvas/grid/carousel)")
+ return when (child.name.lowercase()) {
+ "canvas" -> parseCanvas(child, tree)
+ "grid" -> parseGrid(child, tree)
+ "carousel" -> parseCarousel(child, tree)
+ else -> error("Unknown layout node: ${child.name}")
+ }
+ }
+
+ private fun parseCanvas(node: XmlNode, tree: ThemeTree): LayoutNode.Canvas {
+ // Default to 100% width/height if not specified
+ val w = resolveDimensionWidth(node, "width", tree) ?: Dimension.RelW(1f)
+ val h = resolveDimensionHeight(node, "height", tree) ?: Dimension.RelH(1f)
+ val children = node.children.filter { it.name.equals("child", ignoreCase = true) }.map { ch ->
+ // Support both new "card" and legacy "template" attribute
+ val cardId = ch.attributes["card"] ?: ch.attributes["template"] ?: "default"
+ CanvasChild(
+ cardId = cardId,
+ position = DimOffset(px(ch, "x"), px(ch, "y")),
+ size = size(ch),
+ )
+ }
+ return LayoutNode.Canvas(size = DimSize(w, h), children = children)
+ }
+
+ private fun parseGrid(node: XmlNode, tree: ThemeTree): LayoutNode.Grid {
+ // columns is optional - null means adaptive based on cellWidth
+ val cols = resolveInt(node, "columns", tree)
+ val rows = resolveInt(node, "rows", tree)
+ // cellWidth defaults to 100% (single column) if not specified
+ val cellW = resolveDimensionWidth(node, "cellWidth", tree) ?: Dimension.RelW(1f)
+ // cellHeight is optional - if not specified, aspectRatio or card height will be used
+ val cellH = resolveDimensionHeight(node, "cellHeight", tree)
+ // aspectRatio for automatic height calculation (width/height, e.g. 2.14 for hero, 0.67 for capsule)
+ val aspectRatio = resolveFloatOrNull(node, "aspectRatio", tree)
+
+ // cellSpacing sets both hSpacing and vSpacing; individual values override it
+ val cellSpacing = resolveFloat(node, "cellSpacing", default = 0f, tree)
+ val hSpacing = resolveFloat(node, "hSpacing", default = cellSpacing, tree)
+ val vSpacing = resolveFloat(node, "vSpacing", default = cellSpacing, tree)
+
+ val sel = when (node.attributes["selectionMode"]?.lowercase()) {
+ "stationary" -> SelectionMode.STATIONARY
+ "moving" -> SelectionMode.MOVING
+ null -> SelectionMode.MOVING
+ else -> SelectionMode.MOVING
+ }
+ // Support both new "itemCard" and legacy "itemTemplate" attribute; default to "default" if not specified
+ val itemCard = node.attributes["itemCard"] ?: node.attributes["itemTemplate"] ?: "default"
+
+ // Content padding - supports CSS-like shorthand: 1, 2, 3, or 4 values
+ val (paddingTop, paddingEnd, paddingBottom, paddingStart) = parsePadding(node.attributes["padding"], tree)
+
+ // Parse optional separator
+ val separator = node.children.firstOrNull { it.name.equals("separator", ignoreCase = true) }?.let { sepNode ->
+ // Separator height defaults to 1px if not specified
+ val sepHeight = resolveDimensionHeight(sepNode, "height", tree) ?: Dimension.Px(1f)
+ val sepLayers = sepNode.children.mapIndexedNotNull { index, child -> parseLayer(child, tree, index) }
+ // Parse margin - supports CSS-like shorthand
+ val (marginTop, marginEnd, marginBottom, marginStart) = parsePadding(sepNode.attributes["margin"], tree)
+ GridSeparator(
+ height = sepHeight,
+ layers = sepLayers,
+ marginTop = marginTop,
+ marginBottom = marginBottom,
+ marginStart = marginStart,
+ marginEnd = marginEnd,
+ )
+ }
+
+ // Navigation attributes
+ val navigationId = node.attributes["navigationId"]
+ val navigateUp = node.attributes["navigateUp"]
+ val navigateDown = node.attributes["navigateDown"]
+ val navigateLeft = node.attributes["navigateLeft"]
+ val navigateRight = node.attributes["navigateRight"]
+
+ return LayoutNode.Grid(
+ columns = cols,
+ rows = rows,
+ cellWidth = cellW,
+ cellHeight = cellH,
+ aspectRatio = aspectRatio,
+ hSpacing = hSpacing,
+ vSpacing = vSpacing,
+ selectionMode = sel,
+ itemCard = itemCard,
+ contentPaddingTop = paddingTop,
+ contentPaddingBottom = paddingBottom,
+ contentPaddingStart = paddingStart,
+ contentPaddingEnd = paddingEnd,
+ separator = separator,
+ verticalAlign = VerticalAlign.fromString(node.attributes["verticalAlign"]),
+ navigationId = navigationId,
+ navigateUp = navigateUp,
+ navigateDown = navigateDown,
+ navigateLeft = navigateLeft,
+ navigateRight = navigateRight,
+ )
+ }
+
+ private fun parseCarousel(node: XmlNode, tree: ThemeTree): LayoutNode.Carousel {
+ val dir = when (node.attributes["direction"]?.lowercase()) {
+ "left" -> Direction.LEFT
+ "right" -> Direction.RIGHT
+ "up" -> Direction.UP
+ "down" -> Direction.DOWN
+ else -> Direction.RIGHT
+ }
+ // Default item size to 200x200 if not specified
+ val itemW = resolveDimensionWidth(node, "itemWidth", tree) ?: Dimension.Px(200f)
+ val itemH = resolveDimensionHeight(node, "itemHeight", tree) ?: Dimension.Px(200f)
+ val spacing = resolveFloat(node, "itemSpacing", default = 0f, tree)
+ val sel = when (node.attributes["selectionMode"]?.lowercase()) {
+ "stationary" -> SelectionMode.STATIONARY
+ "moving" -> SelectionMode.MOVING
+ null -> SelectionMode.STATIONARY
+ else -> SelectionMode.STATIONARY
+ }
+ val pageSize = resolveInt(node, "pageSize", tree)
+ // Support both new "itemCard" and legacy "itemTemplate" attribute; default to "default" if not specified
+ val itemCard = node.attributes["itemCard"] ?: node.attributes["itemTemplate"] ?: "default"
+
+ // Center-focus carousel attributes
+ val centerFocus = node.attributes["centerFocus"]?.toBooleanStrictOrNull() ?: false
+ // Support both "focusedScale" (new) and "highlightScale" (legacy) attribute names
+ val focusedScale = resolveFloatOrNull(node, "focusedScale", tree)
+ ?: resolveFloat(node, "highlightScale", default = 1.0f, tree)
+ val unfocusedAlpha = resolveFloat(node, "unfocusedAlpha", default = 1.0f, tree)
+ val verticalAlign = VerticalAlign.fromString(node.attributes["verticalAlign"])
+ val verticalOffset = resolveDimensionWidth(node, "verticalOffset", tree) ?: Dimension.Px(0f)
+
+ // Orientation and alignment (for vertical carousels)
+ val orientation = CarouselOrientation.fromString(node.attributes["orientation"])
+ val horizontalAlign = HorizontalAlign.fromString(node.attributes["horizontalAlign"])
+ val horizontalOffset = resolveDimensionWidth(node, "horizontalOffset", tree) ?: Dimension.Px(0f)
+
+ // Focused item offset and spacing
+ val focusedOffsetX = resolveFloat(node, "focusedOffsetX", default = 0f, tree)
+ val focusedOffsetY = resolveFloat(node, "focusedOffsetY", default = 0f, tree)
+ val focusedSpacing = resolveFloat(node, "focusedSpacing", default = 0f, tree)
+ val beforeFocusOffset = resolveFloat(node, "beforeFocusOffset", default = 0f, tree)
+
+ // Background image attributes
+ val focusedBackground = node.attributes["focusedBackground"]?.let { stringBinding(it) }
+ val backgroundOpacity = resolveFloat(node, "backgroundOpacity", default = 0.3f, tree)
+ val backgroundTransitionSpeed = resolveInt(node, "backgroundTransitionSpeed", tree) ?: 400
+
+ return LayoutNode.Carousel(
+ direction = dir,
+ orientation = orientation,
+ itemSize = DimSize(itemW, itemH),
+ itemSpacing = spacing,
+ selectionMode = sel,
+ itemCard = itemCard,
+ pageSize = pageSize,
+ centerFocus = centerFocus,
+ focusedScale = focusedScale,
+ unfocusedAlpha = unfocusedAlpha,
+ verticalAlign = verticalAlign,
+ verticalOffset = verticalOffset,
+ horizontalAlign = horizontalAlign,
+ horizontalOffset = horizontalOffset,
+ focusedOffsetX = focusedOffsetX,
+ focusedOffsetY = focusedOffsetY,
+ focusedSpacing = focusedSpacing,
+ beforeFocusOffset = beforeFocusOffset,
+ focusedBackground = focusedBackground,
+ backgroundOpacity = backgroundOpacity,
+ backgroundTransitionSpeed = backgroundTransitionSpeed,
+ )
+ }
+
+ /**
+ * Parse a grid node with support for inline card definitions.
+ * If a child is found inside the grid, it will be parsed and used as the item card.
+ * Otherwise, the itemCard attribute is used to reference a pre-defined card.
+ */
+ private fun parseGridWithInlineCard(node: XmlNode, tree: ThemeTree): LayoutNodeWithCard {
+ // Check for inline definition
+ val inlineCardNode = node.children.firstOrNull { it.name.equals("card", ignoreCase = true) }
+
+ val inlineCard = inlineCardNode?.let { cardNode ->
+ // Generate ID: use explicit id attribute, or auto-generate from grid context
+ val cardId = cardNode.attributes["id"] ?: "inline_grid_card_${System.nanoTime()}"
+ val width = resolveDimensionWidth(cardNode, "width", tree) ?: Dimension.RelW(1f)
+ val height = resolveDimensionHeight(cardNode, "height", tree) ?: Dimension.RelH(1f)
+ val layers = cardNode.children.mapIndexedNotNull { index, child -> parseLayer(child, tree, index) }
+ Card(id = cardId, canvas = DimSize(width, height), layers = layers)
+ }
+
+ // Parse the grid, using inline card's ID if present, otherwise use the attribute
+ val effectiveItemCard = inlineCard?.id
+ ?: node.attributes["itemCard"]
+ ?: node.attributes["itemTemplate"]
+ ?: "default"
+
+ // Parse grid with the effective item card ID
+ val grid = parseGridInternal(node, tree, effectiveItemCard)
+
+ return LayoutNodeWithCard(grid, inlineCard)
+ }
+
+ /** Internal grid parser that takes the item card ID as a parameter. */
+ private fun parseGridInternal(node: XmlNode, tree: ThemeTree, itemCardId: String): LayoutNode.Grid {
+ val cols = resolveInt(node, "columns", tree)
+ val rows = resolveInt(node, "rows", tree)
+ val cellW = resolveDimensionWidth(node, "cellWidth", tree) ?: Dimension.RelW(1f)
+ val cellH = resolveDimensionHeight(node, "cellHeight", tree)
+ val aspectRatio = resolveFloatOrNull(node, "aspectRatio", tree)
+
+ val cellSpacing = resolveFloat(node, "cellSpacing", default = 0f, tree)
+ val hSpacing = resolveFloat(node, "hSpacing", default = cellSpacing, tree)
+ val vSpacing = resolveFloat(node, "vSpacing", default = cellSpacing, tree)
+
+ val sel = when (node.attributes["selectionMode"]?.lowercase()) {
+ "stationary" -> SelectionMode.STATIONARY
+ "moving" -> SelectionMode.MOVING
+ null -> SelectionMode.MOVING
+ else -> SelectionMode.MOVING
+ }
+
+ val (paddingTop, paddingEnd, paddingBottom, paddingStart) = parsePadding(node.attributes["padding"], tree)
+
+ // Parse optional separator (skip children)
+ val separator = node.children.firstOrNull { it.name.equals("separator", ignoreCase = true) }?.let { sepNode ->
+ val sepHeight = resolveDimensionHeight(sepNode, "height", tree) ?: Dimension.Px(1f)
+ val sepLayers = sepNode.children.mapIndexedNotNull { index, child -> parseLayer(child, tree, index) }
+ val (marginTop, marginEnd, marginBottom, marginStart) = parsePadding(sepNode.attributes["margin"], tree)
+ GridSeparator(
+ height = sepHeight,
+ layers = sepLayers,
+ marginTop = marginTop,
+ marginBottom = marginBottom,
+ marginStart = marginStart,
+ marginEnd = marginEnd,
+ )
+ }
+
+ // Navigation attributes
+ val navigationId = node.attributes["navigationId"]
+ val navigateUp = node.attributes["navigateUp"]
+ val navigateDown = node.attributes["navigateDown"]
+ val navigateLeft = node.attributes["navigateLeft"]
+ val navigateRight = node.attributes["navigateRight"]
+
+ // Highlight styling
+ val highlightBorderWidth = resolveFloat(node, "highlightBorderWidth", default = 3f, tree)
+ val highlightCornerRadius = resolveFloat(node, "highlightCornerRadius", default = 8f, tree)
+
+ return LayoutNode.Grid(
+ columns = cols,
+ rows = rows,
+ cellWidth = cellW,
+ cellHeight = cellH,
+ aspectRatio = aspectRatio,
+ hSpacing = hSpacing,
+ vSpacing = vSpacing,
+ selectionMode = sel,
+ itemCard = itemCardId,
+ contentPaddingTop = paddingTop,
+ contentPaddingBottom = paddingBottom,
+ contentPaddingStart = paddingStart,
+ contentPaddingEnd = paddingEnd,
+ separator = separator,
+ verticalAlign = VerticalAlign.fromString(node.attributes["verticalAlign"]),
+ navigationId = navigationId,
+ navigateUp = navigateUp,
+ navigateDown = navigateDown,
+ navigateLeft = navigateLeft,
+ navigateRight = navigateRight,
+ highlightBorderWidth = highlightBorderWidth,
+ highlightCornerRadius = highlightCornerRadius,
+ )
+ }
+
+ /**
+ * Parse a carousel node with support for inline card definitions.
+ * If a child is found inside the carousel, it will be parsed and used as the item card.
+ * Otherwise, the itemCard attribute is used to reference a pre-defined card.
+ */
+ private fun parseCarouselWithInlineCard(node: XmlNode, tree: ThemeTree): LayoutNodeWithCard {
+ // Check for inline definition
+ val inlineCardNode = node.children.firstOrNull { it.name.equals("card", ignoreCase = true) }
+
+ val inlineCard = inlineCardNode?.let { cardNode ->
+ // Generate ID: use explicit id attribute, or auto-generate from carousel context
+ val cardId = cardNode.attributes["id"] ?: "inline_carousel_card_${System.nanoTime()}"
+ val width = resolveDimensionWidth(cardNode, "width", tree) ?: Dimension.RelW(1f)
+ val height = resolveDimensionHeight(cardNode, "height", tree) ?: Dimension.RelH(1f)
+ val layers = cardNode.children.mapIndexedNotNull { index, child -> parseLayer(child, tree, index) }
+ Card(id = cardId, canvas = DimSize(width, height), layers = layers)
+ }
+
+ // Parse the carousel, using inline card's ID if present, otherwise use the attribute
+ val effectiveItemCard = inlineCard?.id
+ ?: node.attributes["itemCard"]
+ ?: node.attributes["itemTemplate"]
+ ?: "default"
+
+ // Parse carousel with the effective item card ID
+ val carousel = parseCarouselInternal(node, tree, effectiveItemCard)
+
+ return LayoutNodeWithCard(carousel, inlineCard)
+ }
+
+ /** Internal carousel parser that takes the item card ID as a parameter. */
+ private fun parseCarouselInternal(node: XmlNode, tree: ThemeTree, itemCardId: String): LayoutNode.Carousel {
+ val dir = when (node.attributes["direction"]?.lowercase()) {
+ "left" -> Direction.LEFT
+ "right" -> Direction.RIGHT
+ "up" -> Direction.UP
+ "down" -> Direction.DOWN
+ else -> Direction.RIGHT
+ }
+ val orientation = CarouselOrientation.fromString(node.attributes["orientation"])
+ val itemW = resolveDimensionWidth(node, "itemWidth", tree) ?: Dimension.Px(200f)
+ val itemH = resolveDimensionHeight(node, "itemHeight", tree) ?: Dimension.Px(200f)
+ val spacing = resolveFloat(node, "itemSpacing", default = 0f, tree)
+ val sel = when (node.attributes["selectionMode"]?.lowercase()) {
+ "stationary" -> SelectionMode.STATIONARY
+ "moving" -> SelectionMode.MOVING
+ null -> SelectionMode.STATIONARY
+ else -> SelectionMode.STATIONARY
+ }
+ val pageSize = resolveInt(node, "pageSize", tree)
+
+ val centerFocus = node.attributes["centerFocus"]?.toBooleanStrictOrNull() ?: false
+ // Support both "focusedScale" (new) and "highlightScale" (legacy) attribute names
+ val focusedScale = resolveFloatOrNull(node, "focusedScale", tree)
+ ?: resolveFloat(node, "highlightScale", default = 1.0f, tree)
+ val unfocusedAlpha = resolveFloat(node, "unfocusedAlpha", default = 1.0f, tree)
+ val verticalAlign = VerticalAlign.fromString(node.attributes["verticalAlign"])
+ val verticalOffset = resolveDimensionWidth(node, "verticalOffset", tree) ?: Dimension.Px(0f)
+ val horizontalAlign = HorizontalAlign.fromString(node.attributes["horizontalAlign"])
+ val horizontalOffset = resolveDimensionWidth(node, "horizontalOffset", tree) ?: Dimension.Px(0f)
+
+ val focusedOffsetX = resolveFloat(node, "focusedOffsetX", default = 0f, tree)
+ val focusedOffsetY = resolveFloat(node, "focusedOffsetY", default = 0f, tree)
+ val focusedSpacing = resolveFloat(node, "focusedSpacing", default = 0f, tree)
+ val beforeFocusOffset = resolveFloat(node, "beforeFocusOffset", default = 0f, tree)
+
+ val focusedBackground = node.attributes["focusedBackground"]?.let { stringBinding(it) }
+ val backgroundOpacity = resolveFloat(node, "backgroundOpacity", default = 0.3f, tree)
+ val backgroundTransitionSpeed = resolveInt(node, "backgroundTransitionSpeed", tree) ?: 400
+
+ // Navigation attributes
+ val navigationId = node.attributes["navigationId"]
+ val navigateUp = node.attributes["navigateUp"]
+ val navigateDown = node.attributes["navigateDown"]
+ val navigateLeft = node.attributes["navigateLeft"]
+ val navigateRight = node.attributes["navigateRight"]
+
+ // Highlight styling
+ val highlightBorderWidth = resolveFloat(node, "highlightBorderWidth", default = 3f, tree)
+ val highlightCornerRadius = resolveFloat(node, "highlightCornerRadius", default = 8f, tree)
+
+ return LayoutNode.Carousel(
+ direction = dir,
+ orientation = orientation,
+ itemSize = DimSize(itemW, itemH),
+ itemSpacing = spacing,
+ selectionMode = sel,
+ itemCard = itemCardId,
+ pageSize = pageSize,
+ centerFocus = centerFocus,
+ focusedScale = focusedScale,
+ unfocusedAlpha = unfocusedAlpha,
+ verticalAlign = verticalAlign,
+ verticalOffset = verticalOffset,
+ horizontalAlign = horizontalAlign,
+ horizontalOffset = horizontalOffset,
+ focusedOffsetX = focusedOffsetX,
+ focusedOffsetY = focusedOffsetY,
+ focusedSpacing = focusedSpacing,
+ beforeFocusOffset = beforeFocusOffset,
+ focusedBackground = focusedBackground,
+ backgroundOpacity = backgroundOpacity,
+ backgroundTransitionSpeed = backgroundTransitionSpeed,
+ navigationId = navigationId,
+ navigateUp = navigateUp,
+ navigateDown = navigateDown,
+ navigateLeft = navigateLeft,
+ navigateRight = navigateRight,
+ highlightBorderWidth = highlightBorderWidth,
+ highlightCornerRadius = highlightCornerRadius,
+ )
+ }
+ // endregion
+
+ // region Helpers
+ private fun req(node: XmlNode, key: String): String =
+ node.attributes[key] ?: error("Missing required attribute '$key' on <${node.name}>")
+
+ private fun reqFloat(node: XmlNode, key: String): Float =
+ node.attributes[key]?.toFloatOrNull()
+ ?: error("Attribute '$key' on <${node.name}> must be a number")
+
+ private fun reqInt(node: XmlNode, key: String): Int =
+ node.attributes[key]?.toIntOrNull()
+ ?: error("Attribute '$key' on <${node.name}> must be an integer")
+
+ private fun size(n: XmlNode): DimSize? {
+ val wAttr = n.attributes["width"]
+ val hAttr = n.attributes["height"]
+ if (wAttr == null || hAttr == null) return null
+ val w = parseDimensionWidth(wAttr) ?: return null
+ val h = parseDimensionHeight(hAttr) ?: return null
+ return DimSize(w, h)
+ }
+
+ private fun px(n: XmlNode, key: String): Dimension {
+ val attr = n.attributes[key] ?: return Dimension.Px(0f)
+ // For x position, use width-relative; for y position, use height-relative
+ return if (key == "y" || key == "dy") {
+ parseDimensionHeight(attr) ?: Dimension.Px(0f)
+ } else {
+ parseDimensionWidth(attr) ?: Dimension.Px(0f)
+ }
+ }
+
+ /**
+ * Parse a dimension value that can be either pixels or percentage.
+ * Percentages are relative to parent width.
+ * - "100" → Dimension.Px(100f)
+ * - "50%" → Dimension.RelW(0.5f)
+ */
+ private fun parseDimensionWidth(value: String): Dimension? {
+ val trimmed = value.trim()
+ return when {
+ trimmed.endsWith("%") -> {
+ val percent = trimmed.dropLast(1).toFloatOrNull() ?: return null
+ Dimension.RelW(percent / 100f)
+ }
+ else -> {
+ val px = trimmed.toFloatOrNull() ?: return null
+ Dimension.Px(px)
+ }
+ }
+ }
+
+ /**
+ * Parse a dimension value that can be either pixels or percentage.
+ * Percentages are relative to parent height.
+ * - "100" → Dimension.Px(100f)
+ * - "50%" → Dimension.RelH(0.5f)
+ */
+ private fun parseDimensionHeight(value: String): Dimension? {
+ val trimmed = value.trim()
+ return when {
+ trimmed.endsWith("%") -> {
+ val percent = trimmed.dropLast(1).toFloatOrNull() ?: return null
+ Dimension.RelH(percent / 100f)
+ }
+ else -> {
+ val px = trimmed.toFloatOrNull() ?: return null
+ Dimension.Px(px)
+ }
+ }
+ }
+
+ /**
+ * Parse a required dimension for width (returns Px or RelW).
+ */
+ private fun reqDimensionWidth(node: XmlNode, key: String): Dimension {
+ val attr = node.attributes[key] ?: error("Missing required attribute '$key' on <${node.name}>")
+ return parseDimensionWidth(attr) ?: error("Invalid dimension value '$attr' for '$key' on <${node.name}>")
+ }
+
+ /**
+ * Parse a required dimension for height (returns Px or RelH).
+ */
+ private fun reqDimensionHeight(node: XmlNode, key: String): Dimension {
+ val attr = node.attributes[key] ?: error("Missing required attribute '$key' on <${node.name}>")
+ return parseDimensionHeight(attr) ?: error("Invalid dimension value '$attr' for '$key' on <${node.name}>")
+ }
+
+ private fun stringBinding(raw: String?): StringOrBinding? {
+ val s = raw ?: return null
+ return if (isBinding(s)) StringOrBinding.Ref(Binding(bindingPath(s))) else StringOrBinding.Literal(s)
+ }
+
+ private fun floatBinding(raw: String?): FloatOrBinding? {
+ val s = raw ?: return null
+ return if (isBinding(s)) FloatOrBinding.Ref(Binding(bindingPath(s))) else FloatOrBinding.Literal(s.toFloatOrNull() ?: 0f)
+ }
+
+ private fun intBinding(raw: String?): IntOrBinding? {
+ val s = raw ?: return null
+ return when {
+ isBinding(s) -> IntOrBinding.Ref(Binding(bindingPath(s)))
+ isColorRef(s) -> IntOrBinding.Ref(Binding(s)) // Keep @color/primary as binding path
+ else -> IntOrBinding.Literal(parseColor(s))
+ }
+ }
+
+ /** Check for @color/ system color reference */
+ private fun isColorRef(s: String): Boolean = s.startsWith("@color/")
+
+ private fun parseColor(s: String): Int {
+ // Supports #AARRGGBB or 0xAARRGGBB; also #RRGGBB (assume opaque)
+ var v = s.trim()
+ val isHex = v.startsWith("#") || v.lowercase().startsWith("0x")
+ if (!isHex) return v.toLongOrNull()?.toInt() ?: 0 // fallback for arbitrary ints
+ v = v.removePrefix("#").removePrefix("0x").removePrefix("0X")
+ val value = v.toLong(16)
+ return if (v.length <= 6) {
+ // RRGGBB -> assume opaque
+ (0xFF000000 or value).toInt()
+ } else value.toInt() // AARRGGBB
+ }
+
+ // ----- Centralized Variable Resolution (delegating to VariableResolver) -----
+
+ private fun isBinding(s: String): Boolean = VariableResolver.isBinding(s)
+ private fun bindingPath(s: String): String = VariableResolver.getBindingPath(s)
+
+ /** Extract binding path from @{...} syntax for visibleWhen attributes. */
+ private fun extractBindingPath(s: String): String {
+ return if (isBinding(s)) bindingPath(s) else s
+ }
+
+ /**
+ * Resolve a raw string value using the variable resolver.
+ * Handles both single variable bindings and embedded variables in strings.
+ */
+ private fun resolveRawValue(value: String?, tree: ThemeTree): String? {
+ return VariableResolver.resolveAllVariables(value, tree.variables)
+ }
+
+ private fun resolveFloat(node: XmlNode, key: String, default: Float, tree: ThemeTree): Float {
+ return VariableResolver.resolveFloat(node.attributes[key], tree.variables, default)
+ }
+
+ /** Resolve an optional float that may be a variable reference. Returns null if not present or invalid. */
+ private fun resolveFloatOrNull(node: XmlNode, key: String, tree: ThemeTree): Float? {
+ return VariableResolver.resolveFloatOrNull(node.attributes[key], tree.variables)
+ }
+
+ /** Resolve an optional int that may be a variable reference. Returns null if not present or invalid. */
+ private fun resolveInt(node: XmlNode, key: String, tree: ThemeTree): Int? {
+ return VariableResolver.resolveIntOrNull(node.attributes[key], tree.variables)
+ }
+
+ /** Resolve a string attribute, expanding variable references. */
+ private fun resolveString(node: XmlNode, key: String, tree: ThemeTree): String? {
+ return VariableResolver.resolveValue(node.attributes[key], tree.variables)
+ }
+
+ /** Resolve a dimension (width-relative) that may be a variable reference. */
+ private fun resolveDimensionWidth(node: XmlNode, key: String, tree: ThemeTree): Dimension? {
+ val resolved = resolveRawValue(node.attributes[key], tree) ?: return null
+ return parseDimensionWidth(resolved)
+ }
+
+ /** Resolve a dimension (height-relative) that may be a variable reference. */
+ private fun resolveDimensionHeight(node: XmlNode, key: String, tree: ThemeTree): Dimension? {
+ val resolved = resolveRawValue(node.attributes[key], tree) ?: return null
+ return parseDimensionHeight(resolved)
+ }
+
+ /** Resolve a position dimension (px) with variable support. */
+ private fun pxResolved(n: XmlNode, key: String, tree: ThemeTree): Dimension {
+ val resolved = resolveRawValue(n.attributes[key], tree) ?: return Dimension.Px(0f)
+ // For x position, use width-relative; for y position, use height-relative
+ return if (key == "y" || key == "dy") {
+ parseDimensionHeight(resolved) ?: Dimension.Px(0f)
+ } else {
+ parseDimensionWidth(resolved) ?: Dimension.Px(0f)
+ }
+ }
+
+ /** Resolve size with variable support. Returns null only if BOTH width and height are missing. */
+ private fun sizeResolved(n: XmlNode, tree: ThemeTree): DimSize? {
+ val wResolved = resolveRawValue(n.attributes["width"], tree)
+ val hResolved = resolveRawValue(n.attributes["height"], tree)
+
+ // If both are missing, return null
+ if (wResolved == null && hResolved == null) return null
+
+ // Parse dimensions, using null for unspecified values (will use Dimension.Unspecified)
+ val w = wResolved?.let { parseDimensionWidth(it) }
+ val h = hResolved?.let { parseDimensionHeight(it) }
+
+ // Return size with available dimensions (Dimension.Unspecified for missing ones)
+ return DimSize(
+ w ?: Dimension.Unspecified,
+ h ?: Dimension.Unspecified
+ )
+ }
+
+ /** Create a FloatOrBinding, resolving variable references to literal values. */
+ private fun floatBindingResolved(raw: String?, tree: ThemeTree): FloatOrBinding? {
+ val s = raw ?: return null
+ return when {
+ VariableResolver.isVariableBinding(s) -> {
+ // Variable binding - resolve to literal
+ val resolved = VariableResolver.resolveFloatOrNull(s, tree.variables)
+ FloatOrBinding.Literal(resolved ?: 0f)
+ }
+ isBinding(s) -> {
+ // Other binding (e.g., @{game.x}) - keep as reference
+ FloatOrBinding.Ref(Binding(bindingPath(s)))
+ }
+ else -> FloatOrBinding.Literal(s.toFloatOrNull() ?: 0f)
+ }
+ }
+
+ /** Create an IntOrBinding, resolving variable references to literal values. */
+ private fun intBindingResolved(raw: String?, tree: ThemeTree): IntOrBinding? {
+ val s = raw ?: return null
+ return when {
+ VariableResolver.isVariableBinding(s) -> {
+ // Variable binding - resolve to literal color
+ val resolved = VariableResolver.resolveColorOrNull(s, tree.variables)
+ ?: return null
+ IntOrBinding.Literal(resolved)
+ }
+ isBinding(s) -> {
+ // Other binding (e.g., @{game.compatibility.color}) - keep as reference
+ IntOrBinding.Ref(Binding(bindingPath(s)))
+ }
+ isColorRef(s) -> IntOrBinding.Ref(Binding(s)) // Keep @color/primary as binding path
+ else -> IntOrBinding.Literal(parseColor(s))
+ }
+ }
+
+ /**
+ * Resolve a string attribute with variable support. Returns the resolved literal string.
+ * Handles both single variable bindings and multi-value strings with embedded variables.
+ * Example: "@{vars.radius} @{vars.radius} 0 0" -> "20 20 0 0"
+ */
+ private fun resolveStringAttr(node: XmlNode, key: String, tree: ThemeTree): String? {
+ return VariableResolver.resolveAllVariables(node.attributes[key], tree.variables)
+ }
+
+ /** Resolve a color attribute with variable support. Returns the parsed color int. */
+ private fun resolveColorAttr(node: XmlNode, key: String, tree: ThemeTree): Int? {
+ return VariableResolver.resolveColorOrNull(node.attributes[key], tree.variables)
+ }
+
+ /**
+ * Parse CSS-like padding shorthand.
+ * - 1 value: all sides get the same value
+ * - 2 values: top/bottom, start/end (vertical, horizontal)
+ * - 3 values: top, start/end, bottom
+ * - 4 values: top, end, bottom, start (clockwise from top)
+ * @return Quadruple of (top, end, bottom, start)
+ */
+ private data class PaddingValues(val top: Float, val end: Float, val bottom: Float, val start: Float)
+
+ private fun parsePadding(value: String?, tree: ThemeTree): PaddingValues {
+ if (value.isNullOrBlank()) return PaddingValues(0f, 0f, 0f, 0f)
+
+ val parts = value.trim().split("\\s+".toRegex()).map { part ->
+ VariableResolver.resolveFloat(part, tree.variables, 0f)
+ }
+
+ return when (parts.size) {
+ 0 -> PaddingValues(0f, 0f, 0f, 0f)
+ 1 -> PaddingValues(parts[0], parts[0], parts[0], parts[0])
+ 2 -> PaddingValues(
+ top = parts[0],
+ end = parts[1],
+ bottom = parts[0],
+ start = parts[1]
+ )
+ 3 -> PaddingValues(
+ top = parts[0],
+ end = parts[1],
+ bottom = parts[2],
+ start = parts[1]
+ )
+ else -> PaddingValues(
+ top = parts[0],
+ end = parts[1],
+ bottom = parts[2],
+ start = parts[3]
+ )
+ }
+ }
+ // endregion
+}
diff --git a/app/src/main/java/app/gamenative/theme/media/AssetResolver.kt b/app/src/main/java/app/gamenative/theme/media/AssetResolver.kt
new file mode 100644
index 000000000..b4e4b7277
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/media/AssetResolver.kt
@@ -0,0 +1,184 @@
+package app.gamenative.theme.media
+
+import java.io.File
+import java.util.LinkedHashMap
+
+/**
+ * Error produced while resolving media assets.
+ */
+data class MediaError(
+ val code: String,
+ val message: String,
+)
+
+/** Result of resolving a logical media reference to a concrete URI. */
+data class AssetResult(
+ /** Fully-qualified URI or file path. For local files, returns a file:// URI. */
+ val uri: String?,
+ /** True if a fallback path was used instead of the primary. */
+ val usedFallback: Boolean = false,
+ /** Collected non-fatal errors and warnings. */
+ val errors: List = emptyList(),
+)
+
+/**
+ * Simple in-memory LRU cache for resolved assets (string-in -> string-out).
+ * Keys should include enough context (e.g., media kind and allowVideo flag).
+ */
+class MediaCache(private val capacity: Int = 128) {
+ private val map = object : LinkedHashMap(capacity, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean {
+ return size > capacity
+ }
+ }
+
+ fun get(key: String): String? = synchronized(map) { map[key] }
+ fun put(key: String, value: String) { synchronized(map) { map[key] = value } }
+ fun clear() { synchronized(map) { map.clear() } }
+}
+
+/**
+ * Abstraction for optional remote checks/fetches. Implementations may perform HEAD
+ * requests to verify existence. Default stub returns false (not found).
+ */
+interface RemoteFetcher {
+ /** Returns true if the remote resource appears reachable. Default: false (no network). */
+ fun exists(url: String): Boolean = false
+}
+
+/**
+ * AssetResolver maps logical names (e.g., "game.capsule") or theme-relative paths
+ * to concrete URIs. It performs local existence checks and uses a small cache for images.
+ */
+class AssetResolver(
+ private val imageCache: MediaCache = MediaCache(),
+ private val remoteFetcher: RemoteFetcher = object : RemoteFetcher {},
+) {
+ /**
+ * Resolve an image path. Applies simple caching and fallback chain.
+ * @param logical Primary logical path or URI (e.g., "assets/card.png", "game.capsule", "http://...").
+ * @param fallbacks Optional ordered list of fallback logical paths.
+ * @param data Optional map providing values for logical keys (e.g., game.capsule -> file://...)
+ * @param themeRoot Optional theme directory for resolving relative paths.
+ */
+ fun resolveImage(
+ logical: String?,
+ fallbacks: List = emptyList(),
+ data: Map = emptyMap(),
+ themeRoot: String? = null,
+ ): AssetResult {
+ if (logical.isNullOrBlank()) {
+ return resolveWithFallbacks(null, fallbacks, data, themeRoot, isVideo = false)
+ }
+ val cacheKey = "img|$logical|${themeRoot ?: ""}"
+ imageCache.get(cacheKey)?.let { cached ->
+ return AssetResult(uri = cached, usedFallback = false, errors = emptyList())
+ }
+ val primary = resolveOne(logical, data, themeRoot, isVideo = false)
+ if (primary.uri != null) {
+ imageCache.put(cacheKey, primary.uri)
+ return primary
+ }
+ val fb = resolveWithFallbacks(logical, fallbacks, data, themeRoot, isVideo = false)
+ if (fb.uri != null) imageCache.put(cacheKey, fb.uri)
+ return fb
+ }
+
+ /** Resolve a video path (no caching here; caching is coordinated by the video manager). */
+ fun resolveVideo(
+ logical: String?,
+ data: Map = emptyMap(),
+ themeRoot: String? = null,
+ ): AssetResult {
+ if (logical.isNullOrBlank()) return AssetResult(uri = null, errors = listOf(MediaError("VIDEO_SRC_MISSING", "Video source is empty")))
+ return resolveOne(logical, data, themeRoot, isVideo = true)
+ }
+
+ // --- Internal helpers ---
+
+ private fun resolveWithFallbacks(
+ primary: String?,
+ fallbacks: List,
+ data: Map,
+ themeRoot: String?,
+ isVideo: Boolean,
+ ): AssetResult {
+ val errs = mutableListOf()
+ if (!primary.isNullOrBlank()) {
+ val p = resolveOne(primary, data, themeRoot, isVideo)
+ errs += p.errors
+ if (p.uri != null) return p
+ }
+ for (fb in fallbacks) {
+ if (fb.isNullOrBlank()) continue
+ val r = resolveOne(fb, data, themeRoot, isVideo)
+ errs += r.errors
+ if (r.uri != null) return AssetResult(uri = r.uri, usedFallback = true, errors = errs)
+ }
+ val label = if (isVideo) "video" else "image"
+ errs += MediaError(code = "${label.uppercase()}_NOT_FOUND", message = "No $label found after trying primary and fallbacks")
+ return AssetResult(uri = null, usedFallback = true, errors = errs)
+ }
+
+ private fun resolveOne(
+ logical: String,
+ data: Map,
+ themeRoot: String?,
+ isVideo: Boolean,
+ ): AssetResult {
+ // 1) If data map provides a concrete value for this logical key, use it.
+ data[logical]?.let { mapped ->
+ return verifyAndNormalize(mapped, themeRoot)
+ }
+ // 2) Already a full URI?
+ if (isUri(logical)) return verifyAndNormalize(logical, themeRoot)
+ // 3) Windows absolute path? normalize to file://
+ if (looksLikeWindowsPath(logical)) return verifyAndNormalize("file://$logical", themeRoot)
+ // 4) Theme-relative path
+ if (themeRoot != null) {
+ val candidate = File(themeRoot, logical)
+ return if (candidate.exists()) AssetResult(uri = toFileUri(candidate))
+ else AssetResult(uri = null, errors = listOf(MediaError("FILE_NOT_FOUND", "File not found: ${candidate.absolutePath}")))
+ }
+ // 5) Unknown logical key without mapping
+ return AssetResult(
+ uri = null,
+ errors = listOf(MediaError(code = "UNKNOWN_LOGICAL", message = "Unknown logical path: $logical")),
+ )
+ }
+
+ private fun verifyAndNormalize(uriOrPath: String, themeRoot: String?): AssetResult {
+ // Try to parse as URI; if it has no scheme, treat as file path.
+ return try {
+ val u = java.net.URI(uriOrPath)
+ val scheme = u.scheme?.lowercase()
+ when (scheme) {
+ null -> { // treat as file system path
+ val f = File(uriOrPath)
+ if (f.exists()) AssetResult(uri = toFileUri(f))
+ else AssetResult(uri = null, errors = listOf(MediaError("FILE_NOT_FOUND", "File not found: ${f.absolutePath}")))
+ }
+ "http", "https" -> {
+ val ok = remoteFetcher.exists(uriOrPath)
+ if (ok) AssetResult(uri = uriOrPath) else AssetResult(uri = null, errors = listOf(MediaError("REMOTE_UNAVAILABLE", "Remote not reachable: $uriOrPath")))
+ }
+ "file" -> {
+ val f = File(u)
+ if (f.exists()) AssetResult(uri = toFileUri(f))
+ else AssetResult(uri = null, errors = listOf(MediaError("FILE_NOT_FOUND", "File not found: ${f.absolutePath}")))
+ }
+ else -> AssetResult(uri = u.toString()) // Other schemes (content:// etc.) assumed valid
+ }
+ } catch (e: Exception) {
+ // Fallback: assume it's a file path
+ val f = File(uriOrPath)
+ if (f.exists()) AssetResult(uri = toFileUri(f))
+ else AssetResult(uri = null, errors = listOf(MediaError("FILE_NOT_FOUND", "File not found: ${f.absolutePath}")))
+ }
+ }
+
+ private fun toFileUri(file: File): String = "file://" + file.absolutePath.replace('\\', '/')
+
+ private fun isUri(s: String): Boolean = s.startsWith("file:") || s.contains("://") || s.startsWith("content://")
+ private fun looksLikeWindowsPath(s: String): Boolean = s.length >= 3 && s[1] == ':' && (s[2] == '\\' || s[2] == '/')
+}
diff --git a/app/src/main/java/app/gamenative/theme/media/MediaSourceManager.kt b/app/src/main/java/app/gamenative/theme/media/MediaSourceManager.kt
new file mode 100644
index 000000000..3a3549bee
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/media/MediaSourceManager.kt
@@ -0,0 +1,117 @@
+package app.gamenative.theme.media
+
+import app.gamenative.theme.model.Binding
+import app.gamenative.theme.model.IntOrBinding
+import app.gamenative.theme.model.MediaSource
+import app.gamenative.theme.model.StringOrBinding
+
+/**
+ * Result of resolving a theme MediaSource into a concrete, renderable asset.
+ */
+sealed class ResolvedMedia {
+ data class Image(
+ val uri: String?,
+ val usedFallback: Boolean,
+ val errors: List = emptyList(),
+ ) : ResolvedMedia()
+
+ data class Video(
+ val uri: String?,
+ val posterUri: String?,
+ val autoplay: Boolean,
+ val loop: Boolean,
+ val muted: Boolean,
+ val usedFallback: Boolean,
+ val errors: List = emptyList(),
+ ) : ResolvedMedia()
+}
+
+/**
+ * MediaSourceManager resolves image and video media sources from the theme model.
+ * It applies graceful fallbacks (poster/fallback image) and keeps a simple cache for images.
+ */
+class MediaSourceManager(
+ private val assetResolver: AssetResolver = AssetResolver(),
+) {
+ /**
+ * Resolve the given [MediaSource] into [ResolvedMedia]. Never throws; errors are returned inline.
+ * @param allowVideo If false, videos are not allowed and poster (or fallback image) will be returned instead.
+ * @param bindingResolver Resolves a [Binding] to a concrete string (e.g., mapping `game.capsule` to a URI). May return null.
+ * @param themeRoot Optional theme directory used to resolve relative paths.
+ */
+ fun resolve(
+ media: MediaSource,
+ allowVideo: Boolean,
+ bindingResolver: (Binding) -> String?,
+ themeRoot: String? = null,
+ ): ResolvedMedia = when (media) {
+ is MediaSource.Image -> resolveImage(media, bindingResolver, themeRoot)
+ is MediaSource.Video -> resolveVideo(media, allowVideo, bindingResolver, themeRoot)
+ }
+
+ private fun resolveImage(
+ image: MediaSource.Image,
+ bindingResolver: (Binding) -> String?,
+ themeRoot: String?,
+ ): ResolvedMedia.Image {
+ val primary = eval(image.src, bindingResolver)
+ val fb = image.fallback?.let { eval(it, bindingResolver) }
+ val res = assetResolver.resolveImage(
+ logical = primary,
+ fallbacks = listOf(fb),
+ data = emptyMap(),
+ themeRoot = themeRoot,
+ )
+ return ResolvedMedia.Image(uri = res.uri, usedFallback = res.usedFallback, errors = res.errors)
+ }
+
+ private fun resolveVideo(
+ video: MediaSource.Video,
+ allowVideo: Boolean,
+ bindingResolver: (Binding) -> String?,
+ themeRoot: String?,
+ ): ResolvedMedia {
+ val src = eval(video.src, bindingResolver)
+ val poster = video.poster?.let { eval(it, bindingResolver) }
+ val fbImg = video.fallbackImage?.let { eval(it, bindingResolver) }
+
+ if (!allowVideo) {
+ // Return poster (or fallback image) as an Image result with a warning.
+ val posterRes = assetResolver.resolveImage(poster, fallbacks = listOf(fbImg), themeRoot = themeRoot)
+ val errs = posterRes.errors + MediaError("VIDEO_NOT_ALLOWED", "Video playback is not allowed in this context; using poster/fallback image")
+ return ResolvedMedia.Image(uri = posterRes.uri, usedFallback = posterRes.usedFallback, errors = errs)
+ }
+
+ // Try resolving the video source.
+ val videoRes = assetResolver.resolveVideo(src, themeRoot = themeRoot)
+ // Always resolve poster if provided (non-blocking); it is useful before first frame.
+ val posterRes = if (poster != null) assetResolver.resolveImage(poster, themeRoot = themeRoot) else AssetResult(uri = null)
+
+ return if (videoRes.uri != null) {
+ ResolvedMedia.Video(
+ uri = videoRes.uri,
+ posterUri = posterRes.uri,
+ autoplay = video.autoplay,
+ loop = video.loop,
+ muted = video.muted,
+ usedFallback = false,
+ errors = (videoRes.errors + posterRes.errors),
+ )
+ } else {
+ // Video unavailable; fall back to fallbackImage, else poster.
+ val fb = assetResolver.resolveImage(fbImg, themeRoot = themeRoot)
+ if (fb.uri != null) {
+ val errs = videoRes.errors + fb.errors + MediaError("VIDEO_FALLBACK_IMAGE_USED", "Video unavailable; using fallback image")
+ ResolvedMedia.Image(uri = fb.uri, usedFallback = true, errors = errs)
+ } else {
+ val errs = videoRes.errors + posterRes.errors + MediaError("VIDEO_UNAVAILABLE", "Video unavailable; using poster if available")
+ ResolvedMedia.Image(uri = posterRes.uri, usedFallback = true, errors = errs)
+ }
+ }
+ }
+
+ private fun eval(value: StringOrBinding, bindingResolver: (Binding) -> String?): String? = when (value) {
+ is StringOrBinding.Literal -> value.value
+ is StringOrBinding.Ref -> bindingResolver(value.binding)
+ }
+}
diff --git a/app/src/main/java/app/gamenative/theme/model/Binding.kt b/app/src/main/java/app/gamenative/theme/model/Binding.kt
new file mode 100644
index 000000000..d47fc83b7
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Binding.kt
@@ -0,0 +1,57 @@
+package app.gamenative.theme.model
+
+/**
+ * Describes a variable that can be defined in a theme. Variables may be declared in a separate
+ * `variables.xml` or inline within `theme.xml` and can be referenced by bindings.
+ */
+data class Variable(
+ /** Unique identifier for this variable within the theme scope. */
+ val id: String,
+ /** Type of the variable value (string, int, float, bool, color). */
+ val type: ValueType,
+ /** Default value encoded as a string; parser/validator will convert to [type]. */
+ val defaultValue: String? = null,
+)
+
+/**
+ * A lightweight binding expression that points at a data source path, e.g. `game.title` or
+ * `game.capsule`. Resolved at render time by the BindingEngine.
+ */
+data class Binding(
+ /** Dot-separated path within the data model (e.g., `game.title`). */
+ val path: String,
+)
+
+/**
+ * Represents either a literal string value or a binding expression that resolves to a string.
+ * Use this for text fields and URIs.
+ */
+sealed class StringOrBinding {
+ /** A fixed literal string value. */
+ data class Literal(val value: String) : StringOrBinding()
+
+ /** A reference to a binding expression. */
+ data class Ref(val binding: Binding) : StringOrBinding()
+}
+
+/**
+ * Represents either a literal float value or a binding expression that resolves to a float.
+ */
+sealed class FloatOrBinding {
+ /** A fixed literal float value. */
+ data class Literal(val value: Float) : FloatOrBinding()
+
+ /** A reference to a binding expression. */
+ data class Ref(val binding: Binding) : FloatOrBinding()
+}
+
+/**
+ * Represents either a literal integer (e.g., ARGB color) or a binding expression that resolves to an int.
+ */
+sealed class IntOrBinding {
+ /** A fixed literal int value. */
+ data class Literal(val value: Int) : IntOrBinding()
+
+ /** A reference to a binding expression. */
+ data class Ref(val binding: Binding) : IntOrBinding()
+}
diff --git a/app/src/main/java/app/gamenative/theme/model/Breakpoint.kt b/app/src/main/java/app/gamenative/theme/model/Breakpoint.kt
new file mode 100644
index 000000000..c7802c905
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Breakpoint.kt
@@ -0,0 +1,103 @@
+package app.gamenative.theme.model
+
+/**
+ * Represents a responsive breakpoint that can override variable values
+ * based on screen orientation or width.
+ *
+ * Breakpoints are evaluated at render time and applied in order (later breakpoints
+ * override earlier ones, similar to CSS cascade).
+ *
+ * Simple usage (recommended):
+ * ```xml
+ *
+ *
+ *
+ * ```
+ *
+ * Advanced usage (pixel-based):
+ * ```xml
+ *
+ *
+ *
+ * ```
+ */
+data class Breakpoint(
+ /** Simple orientation-based breakpoint. Takes precedence over minWidth/maxWidth if set. */
+ val orientation: Orientation? = null,
+ /** Minimum screen width in dp for this breakpoint to apply. */
+ val minWidth: Int? = null,
+ /** Maximum screen width in dp for this breakpoint to apply. */
+ val maxWidth: Int? = null,
+ /** Variable overrides when this breakpoint matches. */
+ val variables: Map = emptyMap()
+) {
+ /**
+ * Check if this breakpoint matches the current screen configuration.
+ *
+ * @param isPortrait True if screen height > screen width
+ * @param screenWidthDp Current screen width in dp
+ */
+ fun matches(isPortrait: Boolean, screenWidthDp: Int): Boolean {
+ // If orientation is specified, use simple orientation matching
+ if (orientation != null) {
+ return when (orientation) {
+ Orientation.PORTRAIT -> isPortrait
+ Orientation.LANDSCAPE -> !isPortrait
+ }
+ }
+
+ // Otherwise use pixel-based breakpoints
+ val minMatches = minWidth == null || screenWidthDp >= minWidth
+ val maxMatches = maxWidth == null || screenWidthDp <= maxWidth
+ return minMatches && maxMatches
+ }
+}
+
+/**
+ * Screen orientation for breakpoint matching.
+ */
+enum class Orientation {
+ /** Portrait mode: screen height > screen width */
+ PORTRAIT,
+ /** Landscape mode: screen width >= screen height */
+ LANDSCAPE;
+
+ companion object {
+ fun fromString(value: String?): Orientation? = when (value?.lowercase()) {
+ "portrait" -> PORTRAIT
+ "landscape" -> LANDSCAPE
+ else -> null
+ }
+ }
+}
+
+/**
+ * Visibility condition for UI elements.
+ * Controls when an element should be displayed based on screen orientation.
+ */
+enum class Visibility {
+ /** Always visible regardless of orientation (default) */
+ ALWAYS,
+ /** Only visible in portrait mode */
+ PORTRAIT,
+ /** Only visible in landscape mode */
+ LANDSCAPE;
+
+ companion object {
+ fun fromString(value: String?): Visibility = when (value?.lowercase()) {
+ "portrait" -> PORTRAIT
+ "landscape" -> LANDSCAPE
+ else -> ALWAYS
+ }
+ }
+
+ /**
+ * Check if element should be visible given the current orientation.
+ */
+ fun isVisible(isPortrait: Boolean): Boolean = when (this) {
+ ALWAYS -> true
+ PORTRAIT -> isPortrait
+ LANDSCAPE -> !isPortrait
+ }
+}
+
diff --git a/app/src/main/java/app/gamenative/theme/model/Card.kt b/app/src/main/java/app/gamenative/theme/model/Card.kt
new file mode 100644
index 000000000..323595b84
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Card.kt
@@ -0,0 +1,15 @@
+package app.gamenative.theme.model
+
+/**
+ * Defines how a single content item (e.g., a game card) is rendered.
+ * Cards are used in grids and carousels to display individual items.
+ */
+data class Card(
+ /** Unique identifier to reference this card from layouts. */
+ val id: String,
+ /** Canvas size for the card; layers are positioned within this box. */
+ val canvas: DimSize,
+ /** Ordered list of layers, back-to-front. */
+ val layers: List = emptyList(),
+)
+
diff --git a/app/src/main/java/app/gamenative/theme/model/Engine.kt b/app/src/main/java/app/gamenative/theme/model/Engine.kt
new file mode 100644
index 000000000..7ba0c9698
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Engine.kt
@@ -0,0 +1,157 @@
+package app.gamenative.theme.model
+
+/**
+ * Theme engine constants and versioning.
+ */
+object ThemeEngine {
+ /**
+ * The current semantic version of the Theme Engine.
+ * Format: MAJOR.MINOR.PATCH
+ */
+ const val ENGINE_VERSION: String = "1.0.0"
+
+ /**
+ * The current major version number (for quick access).
+ */
+ const val ENGINE_MAJOR: Int = 1
+
+ /**
+ * Check if an engine version constraint matches the current engine version.
+ *
+ * Supported constraint formats (Composer-like):
+ * - "1.0.0" - exact match
+ * - "1.*" or "1.x" or "1.x.x" - any version with major version 1
+ * - "1.2.*" or "1.2.x" - any version 1.2.x
+ * - "^1.0.0" - >=1.0.0 and <2.0.0 (compatible with major version)
+ * - "~1.2.0" - >=1.2.0 and <1.3.0 (compatible with minor version)
+ * - ">=1.0.0" - greater than or equal
+ * - "<=2.0.0" - less than or equal
+ * - ">1.0.0" - greater than
+ * - "<2.0.0" - less than
+ *
+ * @param constraint The version constraint from the theme manifest
+ * @param engineVersion The actual engine version to check against (defaults to ENGINE_VERSION)
+ * @return true if the constraint matches the engine version
+ */
+ fun matchesConstraint(constraint: String, engineVersion: String = ENGINE_VERSION): Boolean {
+ val trimmed = constraint.trim()
+ if (trimmed.isEmpty()) return false
+
+ return when {
+ // Wildcard patterns: 1.*, 1.x, 1.x.x
+ trimmed.contains("*") || trimmed.lowercase().contains("x") -> {
+ matchesWildcard(trimmed, engineVersion)
+ }
+ // Caret: ^1.0.0 means >=1.0.0 <2.0.0
+ trimmed.startsWith("^") -> {
+ matchesCaret(trimmed.substring(1), engineVersion)
+ }
+ // Tilde: ~1.2.0 means >=1.2.0 <1.3.0
+ trimmed.startsWith("~") -> {
+ matchesTilde(trimmed.substring(1), engineVersion)
+ }
+ // Comparison operators
+ trimmed.startsWith(">=") -> {
+ compareSemVer(engineVersion, trimmed.substring(2).trim()) >= 0
+ }
+ trimmed.startsWith("<=") -> {
+ compareSemVer(engineVersion, trimmed.substring(2).trim()) <= 0
+ }
+ trimmed.startsWith(">") -> {
+ compareSemVer(engineVersion, trimmed.substring(1).trim()) > 0
+ }
+ trimmed.startsWith("<") -> {
+ compareSemVer(engineVersion, trimmed.substring(1).trim()) < 0
+ }
+ // Exact match
+ else -> {
+ compareSemVer(engineVersion, trimmed) == 0
+ }
+ }
+ }
+
+ /**
+ * Match wildcard patterns like "1.*", "1.x", "1.2.*", "1.x.x"
+ */
+ private fun matchesWildcard(pattern: String, version: String): Boolean {
+ val patternParts = pattern.replace("*", "x").lowercase().split(".")
+ val versionParts = version.split(".")
+
+ for (i in patternParts.indices) {
+ val patternPart = patternParts[i]
+ if (patternPart == "x") {
+ // Wildcard matches anything from here on
+ return true
+ }
+ val versionPart = versionParts.getOrNull(i)?.toIntOrNull() ?: return false
+ val patternValue = patternPart.toIntOrNull() ?: return false
+ if (versionPart != patternValue) return false
+ }
+ return true
+ }
+
+ /**
+ * Caret matching: ^1.2.3 means >=1.2.3 and <2.0.0
+ * Allows changes that do not modify the left-most non-zero digit.
+ */
+ private fun matchesCaret(constraintVersion: String, version: String): Boolean {
+ val constraintParts = parseSemVer(constraintVersion)
+ val versionParts = parseSemVer(version)
+
+ // Must be >= constraint version
+ if (compareSemVer(version, constraintVersion) < 0) return false
+
+ // Major version must match (for versions >= 1.0.0)
+ if (constraintParts[0] > 0) {
+ return versionParts[0] == constraintParts[0]
+ }
+ // For 0.x.y, minor must match
+ if (constraintParts[1] > 0) {
+ return versionParts[0] == 0 && versionParts[1] == constraintParts[1]
+ }
+ // For 0.0.x, patch must match exactly
+ return versionParts[0] == 0 && versionParts[1] == 0 && versionParts[2] == constraintParts[2]
+ }
+
+ /**
+ * Tilde matching: ~1.2.3 means >=1.2.3 and <1.3.0
+ * Allows patch-level changes.
+ */
+ private fun matchesTilde(constraintVersion: String, version: String): Boolean {
+ val constraintParts = parseSemVer(constraintVersion)
+ val versionParts = parseSemVer(version)
+
+ // Must be >= constraint version
+ if (compareSemVer(version, constraintVersion) < 0) return false
+
+ // Major and minor must match
+ return versionParts[0] == constraintParts[0] && versionParts[1] == constraintParts[1]
+ }
+
+ /**
+ * Parse semantic version string into [major, minor, patch] array.
+ */
+ private fun parseSemVer(version: String): IntArray {
+ val parts = version.trim().split(".")
+ return intArrayOf(
+ parts.getOrNull(0)?.toIntOrNull() ?: 0,
+ parts.getOrNull(1)?.toIntOrNull() ?: 0,
+ parts.getOrNull(2)?.toIntOrNull() ?: 0
+ )
+ }
+
+ /**
+ * Compare two semantic versions.
+ * @return negative if v1 < v2, 0 if equal, positive if v1 > v2
+ */
+ private fun compareSemVer(v1: String, v2: String): Int {
+ val p1 = parseSemVer(v1)
+ val p2 = parseSemVer(v2)
+
+ for (i in 0..2) {
+ val diff = p1[i] - p2[i]
+ if (diff != 0) return diff
+ }
+ return 0
+ }
+}
diff --git a/app/src/main/java/app/gamenative/theme/model/Enums.kt b/app/src/main/java/app/gamenative/theme/model/Enums.kt
new file mode 100644
index 000000000..b0c93b32b
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Enums.kt
@@ -0,0 +1,84 @@
+package app.gamenative.theme.model
+
+/**
+ * Selection behavior for containers.
+ */
+enum class SelectionMode {
+ /** Focus remains fixed; items move under a stationary selection cursor. */
+ STATIONARY,
+ /** Focus moves with selection; typical list/grid behavior. */
+ MOVING
+}
+
+/**
+ * Primary axis directions used by carousels and navigation.
+ */
+enum class Direction {
+ LEFT,
+ RIGHT,
+ UP,
+ DOWN
+}
+
+/**
+ * Supported media kinds in the theme system.
+ */
+enum class MediaKind {
+ IMAGE,
+ VIDEO
+}
+
+/**
+ * Preload policies for video media.
+ */
+enum class VideoPreloadPolicy {
+ NONE,
+ METADATA,
+ AUTO
+}
+
+/**
+ * Value types for variables.
+ */
+enum class ValueType {
+ STRING,
+ INT,
+ FLOAT,
+ BOOL,
+ COLOR
+}
+
+/**
+ * Anchor point for positioning elements (both fixed elements and card layers).
+ * Determines which point of the element the x,y coordinates refer to.
+ */
+enum class Anchor {
+ TOP_LEFT,
+ TOP_CENTER,
+ TOP_RIGHT,
+ CENTER_LEFT,
+ CENTER,
+ CENTER_RIGHT,
+ BOTTOM_LEFT,
+ BOTTOM_CENTER,
+ BOTTOM_RIGHT;
+
+ companion object {
+ /**
+ * Parse anchor from string value (case-insensitive, underscores optional).
+ * Returns TOP_LEFT as default if value is null or unrecognized.
+ */
+ fun fromString(value: String?): Anchor = when (value?.lowercase()?.replace("_", "")) {
+ "topleft" -> TOP_LEFT
+ "topcenter" -> TOP_CENTER
+ "topright" -> TOP_RIGHT
+ "centerleft" -> CENTER_LEFT
+ "center" -> CENTER
+ "centerright" -> CENTER_RIGHT
+ "bottomleft" -> BOTTOM_LEFT
+ "bottomcenter" -> BOTTOM_CENTER
+ "bottomright" -> BOTTOM_RIGHT
+ else -> TOP_LEFT
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/app/gamenative/theme/model/Fixed.kt b/app/src/main/java/app/gamenative/theme/model/Fixed.kt
new file mode 100644
index 000000000..15c80cf36
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Fixed.kt
@@ -0,0 +1,546 @@
+package app.gamenative.theme.model
+
+/**
+ * A container for fixed (static) UI elements that don't scroll with content.
+ * Multiple containers can be defined to group related elements.
+ */
+data class FixedContainer(
+ /** Unique identifier for this container. */
+ val id: String,
+ /** List of fixed elements in this container. */
+ val elements: List = emptyList(),
+ /** Background color (ARGB) for this container, null for transparent. */
+ val backgroundColor: Int? = null,
+ /** Height of the container in pixels, null for auto-size based on content. */
+ val height: Float? = null,
+ /**
+ * Visibility condition based on orientation.
+ * When set, applies to this container and all its children.
+ * Child elements can still override with their own visibility.
+ */
+ val visibility: Visibility = Visibility.ALWAYS,
+ /**
+ * Padding in CSS-style shorthand: "all" or "top right bottom left" (1-4 values).
+ * - "8" = 8px all sides
+ * - "8 16" = 8px top/bottom, 16px left/right
+ * - "8 16 8 16" = top, right, bottom, left
+ */
+ val padding: String? = null,
+ /** Corner radius in pixels for the container background. */
+ val cornerRadius: Float = 0f,
+)
+
+/**
+ * Base class for fixed UI elements (predefined slots that the app fills with functionality).
+ */
+sealed class FixedElement {
+ /** Position of the element. */
+ abstract val position: DimOffset
+ /** Anchor point for positioning. */
+ abstract val anchor: Anchor
+ /** Visibility condition based on orientation. */
+ abstract val visibility: Visibility
+ /** Z-index for stacking order. Higher values render on top. Default is 0. */
+ abstract val zIndex: Float
+ /** Declaration order index (set during parsing). Used for stable sorting when zIndex is equal. */
+ abstract val declarationOrder: Int
+
+ // Highlight styling properties for controller navigation (theme-only feature)
+ /** Highlight border color (ARGB), null = use system primary. */
+ abstract val highlightColor: Int?
+ /** Highlight border opacity (0.0 - 1.0). */
+ abstract val highlightOpacity: Float
+ /** Highlight border width in pixels. */
+ abstract val highlightBorderWidth: Float
+ /** Highlight transition animation duration in milliseconds. */
+ abstract val highlightTransitionSpeed: Int
+
+ // Navigation configuration for controller navigation
+ /**
+ * Custom navigation ID for this element. Used for navigation references (navigateUp/Down/Left/Right).
+ * If null, an auto-generated ID is used internally, but cannot be referenced by other elements.
+ */
+ abstract val navigationId: String?
+ /** Element navigationId to navigate to when pressing UP, null = use spatial navigation. */
+ abstract val navigateUp: String?
+ /** Element navigationId to navigate to when pressing DOWN, null = use spatial navigation. */
+ abstract val navigateDown: String?
+ /** Element navigationId to navigate to when pressing LEFT, null = use spatial navigation. */
+ abstract val navigateLeft: String?
+ /** Element navigationId to navigate to when pressing RIGHT, null = use spatial navigation. */
+ abstract val navigateRight: String?
+
+ /**
+ * App header showing app name, theme name, and game count.
+ */
+ data class Header(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Text color (ARGB). */
+ val textColor: Int = 0xFFFFFFFF.toInt(),
+ /** Whether to show the app name. */
+ val showAppName: Boolean = true,
+ /** Whether to show the theme name. */
+ val showThemeName: Boolean = true,
+ /** Whether to show the game count. */
+ val showGameCount: Boolean = true,
+ /** Optional explicit size for the header area. */
+ val size: DimSize? = null,
+ /** Background color (ARGB), null for transparent. */
+ val backgroundColor: Int? = null,
+ /** Corner radius in pixels for background. */
+ val cornerRadius: Float = 0f,
+ /** Internal padding in pixels. */
+ val padding: Float = 8f,
+ /** Text size for header text elements in pixels. */
+ val textSize: Float = 14f,
+ /** Font weight: "normal", "bold", "medium", "semibold", etc. */
+ val fontWeight: String = "bold",
+ /** Text shadow color (ARGB). Null = no shadow. */
+ val textShadowColor: Int? = null,
+ /** Text shadow blur radius. */
+ val textShadowRadius: Float = 0f,
+ /** Text shadow horizontal offset. */
+ val textShadowOffsetX: Float = 0f,
+ /** Text shadow vertical offset. */
+ val textShadowOffsetY: Float = 0f,
+ ) : FixedElement()
+
+ /**
+ * Search bar for filtering games.
+ */
+ data class SearchBar(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size of the search bar. */
+ val size: DimSize,
+ /** Background color (ARGB), null for default. */
+ val backgroundColor: Int? = null,
+ /** Text/icon color (ARGB), null for default. */
+ val textColor: Int? = null,
+ /** Corner radius in pixels. */
+ val borderRadius: Float = 8f,
+ /** If true, shows only an icon that expands when focused/has text. */
+ val collapsible: Boolean = false,
+ /** Direction to expand when collapsible: "left" or "right". */
+ val expandDirection: String = "left",
+ /** Text shadow color (ARGB). Null = no shadow. */
+ val textShadowColor: Int? = null,
+ /** Text shadow blur radius. */
+ val textShadowRadius: Float = 0f,
+ /** Text shadow horizontal offset. */
+ val textShadowOffsetX: Float = 0f,
+ /** Text shadow vertical offset. */
+ val textShadowOffsetY: Float = 0f,
+ ) : FixedElement()
+
+ /**
+ * User profile/account button.
+ */
+ data class ProfileButton(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_RIGHT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size of the button container in pixels. */
+ val size: Float = 48f,
+ /** Size of the icon inside the button in pixels. */
+ val iconSize: Float = 24f,
+ /** Padding inside the button in pixels. */
+ val padding: Float = 8f,
+ /** Background color (ARGB), null for default. */
+ val backgroundColor: Int? = null,
+ /** Corner radius in pixels. */
+ val cornerRadius: Float = 12f,
+ ) : FixedElement()
+
+ /**
+ * Filter button to open the filter bottom sheet.
+ */
+ data class FilterButton(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.BOTTOM_RIGHT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Whether to show expanded text label. */
+ val expanded: Boolean = true,
+ /** Button size in pixels (diameter for circular, side for square). */
+ val size: Float = 56f,
+ /** Icon size in pixels inside the button. */
+ val iconSize: Float = 24f,
+ /** Background color (ARGB), null uses Material theme primary. */
+ val backgroundColor: Int? = null,
+ /** Icon tint color (ARGB), null uses Material theme onPrimary. */
+ val iconColor: Int? = null,
+ /** Corner radius in pixels, 0 = square, size/2 = circular. */
+ val cornerRadius: Float = 16f,
+ /** Text shadow color (ARGB). Null = no shadow. */
+ val textShadowColor: Int? = null,
+ /** Text shadow blur radius. */
+ val textShadowRadius: Float = 0f,
+ /** Text shadow horizontal offset. */
+ val textShadowOffsetX: Float = 0f,
+ /** Text shadow vertical offset. */
+ val textShadowOffsetY: Float = 0f,
+ /** Padding in CSS-like format: "all", "vertical horizontal", or "top right bottom left". */
+ val padding: String? = null,
+ ) : FixedElement()
+
+ /**
+ * Add button to add custom games.
+ */
+ data class AddButton(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.BOTTOM_RIGHT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Whether to show expanded text label. */
+ val expanded: Boolean = false,
+ /** Button size in pixels (diameter for circular, side for square). */
+ val size: Float = 56f,
+ /** Icon size in pixels inside the button. */
+ val iconSize: Float = 24f,
+ /** Background color (ARGB), null uses Material theme primary. */
+ val backgroundColor: Int? = null,
+ /** Icon tint color (ARGB), null uses Material theme onPrimary. */
+ val iconColor: Int? = null,
+ /** Corner radius in pixels, 0 = square, size/2 = circular. */
+ val cornerRadius: Float = 16f,
+ /** Text shadow color (ARGB). Null = no shadow. */
+ val textShadowColor: Int? = null,
+ /** Text shadow blur radius. */
+ val textShadowRadius: Float = 0f,
+ /** Text shadow horizontal offset. */
+ val textShadowOffsetX: Float = 0f,
+ /** Text shadow vertical offset. */
+ val textShadowOffsetY: Float = 0f,
+ /** Padding in CSS-like format: "all", "vertical horizontal", or "top right bottom left". */
+ val padding: String? = null,
+ ) : FixedElement()
+
+ /**
+ * Static image element (not bound to game data).
+ * Use for decorative images, logos, backgrounds, etc.
+ */
+ data class Image(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size of the image (required). */
+ val size: DimSize,
+ /** Image source URL or asset path. */
+ val src: String,
+ /** How to scale the image: "cover", "contain", "fill", "none". */
+ val scaleType: String = "cover",
+ /** CSS-style corner radius (e.g., "8" or "8 8 0 0"). */
+ val cornerRadius: String? = null,
+ /** Opacity (0.0 - 1.0). */
+ val opacity: Float = 1f,
+ ) : FixedElement()
+
+ /**
+ * Static video element (not bound to game data).
+ * Use for background videos, promotional content, etc.
+ */
+ data class Video(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size of the video (required). */
+ val size: DimSize,
+ /** Video source URL or asset path. */
+ val src: String,
+ /** Poster/thumbnail image to show while loading. */
+ val poster: String? = null,
+ /** Whether to autoplay the video. */
+ val autoplay: Boolean = false,
+ /** Whether to loop the video. */
+ val loop: Boolean = true,
+ /** Whether to mute the video. */
+ val muted: Boolean = true,
+ /** CSS-style corner radius (e.g., "8" or "8 8 0 0"). */
+ val cornerRadius: String? = null,
+ /** Opacity (0.0 - 1.0). */
+ val opacity: Float = 1f,
+ ) : FixedElement()
+
+ /**
+ * Rectangle element for backgrounds, overlays, dividers, etc.
+ * Supports solid colors, gradients, borders, and rounded corners.
+ */
+ data class Rect(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size of the rectangle (required). */
+ val size: DimSize,
+ /** Fill color (ARGB). */
+ val color: Int = 0x00000000,
+ /** CSS-style corner radius (e.g., "8" or "8 8 0 0"). */
+ val cornerRadius: String? = null,
+ /** Border width in pixels, 0 = no border. */
+ val borderWidth: Float = 0f,
+ /** Border color (ARGB). */
+ val borderColor: Int = 0x00000000,
+ /** Gradient start color (ARGB), null for solid fill. */
+ val gradientStart: Int? = null,
+ /** Gradient end color (ARGB). */
+ val gradientEnd: Int? = null,
+ /** Gradient angle in degrees (0 = left-to-right, 90 = top-to-bottom). */
+ val gradientAngle: Float = 0f,
+ /** Opacity (0.0 - 1.0). */
+ val opacity: Float = 1f,
+ ) : FixedElement()
+
+ /**
+ * Static text element (not bound to game data).
+ * Use for decorative text, labels, titles, etc.
+ */
+ data class Text(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size constraint for text wrapping. */
+ val size: DimSize? = null,
+ /** Text content to display. */
+ val text: String,
+ /** Text color (ARGB). */
+ val color: Int = 0xFFFFFFFF.toInt(),
+ /** Text size in sp. */
+ val textSize: Float = 14f,
+ /** Max lines for text wrapping, null = unlimited. */
+ val maxLines: Int? = null,
+ /** Text alignment: "left", "center", or "right". */
+ val textAlign: String = "left",
+ /** Font weight: "normal", "bold", "medium", "semibold", etc. */
+ val fontWeight: String = "normal",
+ /** Font style: "normal" or "italic". */
+ val fontStyle: String = "normal",
+ /** Text overflow behavior: "ellipsis", "clip", or "visible". Defaults to "ellipsis". */
+ val overflow: String = "ellipsis",
+ /** Opacity (0.0 - 1.0). */
+ val opacity: Float = 1f,
+ ) : FixedElement()
+
+ /**
+ * Drop shadow element for visual depth effects.
+ */
+ data class Shadow(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size of the shadow area. */
+ val size: DimSize,
+ /** Shadow blur radius in pixels. */
+ val radius: Float = 8f,
+ /** Shadow color (ARGB). */
+ val color: Int = 0x66000000,
+ /** Shadow X offset in pixels. */
+ val offsetX: Float = 0f,
+ /** Shadow Y offset in pixels. */
+ val offsetY: Float = 4f,
+ /** CSS-style corner radius (e.g., "8" or "8 8 0 0"). */
+ val cornerRadius: String? = null,
+ /** Opacity (0.0 - 1.0). */
+ val opacity: Float = 1f,
+ ) : FixedElement()
+
+ /**
+ * Border/stroke element around a rectangular area.
+ */
+ data class Border(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size of the bordered area. */
+ val size: DimSize,
+ /** Border stroke width in pixels. */
+ val strokeWidth: Float = 1f,
+ /** Border color (ARGB). */
+ val color: Int = 0xFFFFFFFF.toInt(),
+ /** CSS-style corner radius (e.g., "8" or "8 8 0 0"). */
+ val cornerRadius: String? = null,
+ /** Opacity (0.0 - 1.0). */
+ val opacity: Float = 1f,
+ ) : FixedElement()
+
+ /**
+ * Backdrop blur effect element.
+ * Creates a frosted glass effect by blurring content behind it.
+ */
+ data class Backdrop(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Size of the backdrop area. */
+ val size: DimSize,
+ /** Blur radius in pixels. */
+ val blurRadius: Float = 16f,
+ /** Optional tint color (ARGB) overlay. */
+ val tintColor: Int? = null,
+ /** CSS-style corner radius (e.g., "8" or "8 8 0 0"). */
+ val cornerRadius: String? = null,
+ /** Opacity (0.0 - 1.0). */
+ val opacity: Float = 1f,
+ ) : FixedElement()
+
+ /**
+ * System time display element showing the current device time.
+ */
+ data class SystemTime(
+ override val position: DimOffset,
+ override val anchor: Anchor = Anchor.TOP_RIGHT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val highlightColor: Int? = null,
+ override val highlightOpacity: Float = 0.8f,
+ override val highlightBorderWidth: Float = 2f,
+ override val highlightTransitionSpeed: Int = 200,
+ override val navigationId: String? = null,
+ override val navigateUp: String? = null,
+ override val navigateDown: String? = null,
+ override val navigateLeft: String? = null,
+ override val navigateRight: String? = null,
+ /** Text size in sp. */
+ val textSize: Float = 16f,
+ /** Text color (ARGB), null uses theme default white. */
+ val textColor: Int? = null,
+ /** Font weight: "normal", "bold", "medium", "semibold", etc. */
+ val fontWeight: String = "normal",
+ /** Whether to use 24-hour format (false = 12-hour with AM/PM). */
+ val use24Hour: Boolean = false,
+ ) : FixedElement()
+}
+
diff --git a/app/src/main/java/app/gamenative/theme/model/Layers.kt b/app/src/main/java/app/gamenative/theme/model/Layers.kt
new file mode 100644
index 000000000..6e42e0641
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Layers.kt
@@ -0,0 +1,305 @@
+package app.gamenative.theme.model
+
+/**
+ * Base layer definition used inside a [Card].
+ * All layers share common positioning and visibility properties.
+ */
+sealed class Layer {
+ /** Optional developer identifier for the layer. */
+ abstract val id: String?
+
+ /** Common absolute position of the layer within the template canvas. */
+ abstract val position: DimOffset
+
+ /** Optional explicit size; if omitted, content/intrinsic or template size may be used. */
+ abstract val size: DimSize?
+
+ /** Opacity multiplier [0f..1f]. */
+ abstract val opacity: FloatOrBinding?
+
+ /** Anchor point for positioning. Determines which point of the layer the x,y refers to. */
+ abstract val anchor: Anchor
+
+ /** Visibility condition based on orientation. Defaults to ALWAYS (visible in all orientations). */
+ abstract val visibility: Visibility
+
+ /** Z-index for stacking order. Higher values render on top. Default is 0. */
+ abstract val zIndex: Float
+
+ /** Declaration order index (set during parsing). Used for stable sorting when zIndex is equal. */
+ abstract val declarationOrder: Int
+
+ /** If true, this layer is only visible when the card is focused/highlighted. */
+ abstract val focusOnly: Boolean
+
+ /** Duration in ms for focus transition animation (fade in/out). 0 = instant. */
+ abstract val focusTransitionSpeed: Int
+
+ /** Binding path for conditional visibility (e.g., "game.isInstalled"). Layer shows only when binding = "true". */
+ abstract val visibleWhen: String?
+
+ /**
+ * Renders an image.
+ */
+ data class ImageLayer(
+ override val id: String? = null,
+ override val position: DimOffset,
+ override val size: DimSize? = null,
+ override val opacity: FloatOrBinding? = null,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val focusOnly: Boolean = false,
+ override val focusTransitionSpeed: Int = 150,
+ override val visibleWhen: String? = null,
+ /** Source image or binding to an image path. */
+ val source: MediaSource.Image,
+ /**
+ * Corner radius in CSS-like syntax:
+ * - "8" = all corners 8
+ * - "8 4" = top-left/bottom-right 8, top-right/bottom-left 4
+ * - "8 4 2" = top-left 8, top-right/bottom-left 4, bottom-right 2
+ * - "8 4 2 1" = top-left 8, top-right 4, bottom-right 2, bottom-left 1
+ */
+ val cornerRadius: String? = null,
+ /** Optional tint color (ARGB). */
+ val tintColor: IntOrBinding? = null,
+ /**
+ * How the image should be scaled within its bounds (CSS-like):
+ * - "cover" = Fill container, crop if needed (default)
+ * - "contain" = Fit entire image within container
+ * - "stretch" / "fill" = Stretch to fill exactly
+ */
+ val scaleType: String = "cover",
+ ) : Layer()
+
+ /**
+ * Renders a video.
+ */
+ data class VideoLayer(
+ override val id: String? = null,
+ override val position: DimOffset,
+ override val size: DimSize? = null,
+ override val opacity: FloatOrBinding? = null,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val focusOnly: Boolean = false,
+ override val focusTransitionSpeed: Int = 150,
+ override val visibleWhen: String? = null,
+ /** Source video with playback options. */
+ val source: MediaSource.Video,
+ /**
+ * Corner radius in CSS-like syntax (consistent with other layers):
+ * - "8" = all corners 8
+ * - "8 4" = top-left/bottom-right 8, top-right/bottom-left 4
+ * - "8 4 2" = top-left 8, top-right/bottom-left 4, bottom-right 2
+ * - "8 4 2 1" = top-left 8, top-right 4, bottom-right 2, bottom-left 1
+ */
+ val cornerRadius: String? = null,
+ ) : Layer()
+
+ /**
+ * A drawable rectangle shape. Can be used as background, overlay, or any filled rectangle.
+ * Supports fill color, optional border, rounded corners, and gradient fills.
+ */
+ data class RectLayer(
+ override val id: String? = null,
+ override val position: DimOffset,
+ override val size: DimSize? = null,
+ override val opacity: FloatOrBinding? = null,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val focusOnly: Boolean = false,
+ override val focusTransitionSpeed: Int = 150,
+ override val visibleWhen: String? = null,
+ /** Fill color (ARGB). Used as solid fill if no gradient is defined. */
+ val color: IntOrBinding,
+ /**
+ * Corner radius in CSS-like syntax:
+ * - "8" = all corners 8
+ * - "8 4" = top-left/bottom-right 8, top-right/bottom-left 4
+ * - "8 4 2" = top-left 8, top-right/bottom-left 4, bottom-right 2
+ * - "8 4 2 1" = top-left 8, top-right 4, bottom-right 2, bottom-left 1
+ */
+ val cornerRadius: String? = null,
+ /** Border/stroke width in dp. If null or 0, no border is drawn. */
+ val borderWidth: FloatOrBinding? = null,
+ /** Border/stroke color (ARGB). Only used if borderWidth > 0 and borderGradient is false. */
+ val borderColor: IntOrBinding? = null,
+ /** If true, uses the theme's default gradient (tertiary to primary) for the border. */
+ val borderGradient: Boolean = false,
+ /** Gradient start color (ARGB). If set with gradientEnd, renders gradient instead of solid color. */
+ val gradientStart: IntOrBinding? = null,
+ /** Gradient end color (ARGB). Required if gradientStart is set. */
+ val gradientEnd: IntOrBinding? = null,
+ /** Gradient angle in degrees (0 = left to right, 90 = top to bottom). Default 0. */
+ val gradientAngle: FloatOrBinding? = null,
+ ) : Layer()
+
+ /**
+ * Drop shadow rendered for the rectangle defined by [size] at [position].
+ */
+ data class ShadowLayer(
+ override val id: String? = null,
+ override val position: DimOffset,
+ override val size: DimSize? = null,
+ override val opacity: FloatOrBinding? = null,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val focusOnly: Boolean = false,
+ override val focusTransitionSpeed: Int = 150,
+ override val visibleWhen: String? = null,
+ /** Shadow blur radius. */
+ val radius: FloatOrBinding,
+ /** Shadow color (ARGB). */
+ val color: IntOrBinding,
+ /** Shadow offset relative to [position]. */
+ val offset: DimOffset = DimOffset(Dimension.Px(0f), Dimension.Px(0f)),
+ /**
+ * Corner radius in CSS-like syntax for rounded shadow shapes:
+ * - "8" = all corners 8
+ * - "8 4" = top-left/bottom-right 8, top-right/bottom-left 4
+ * - etc.
+ */
+ val cornerRadius: String? = null,
+ ) : Layer()
+
+ /**
+ * Border stroke around the rectangle defined by [size] at [position].
+ */
+ data class BorderLayer(
+ override val id: String? = null,
+ override val position: DimOffset,
+ override val size: DimSize? = null,
+ override val opacity: FloatOrBinding? = null,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val focusOnly: Boolean = false,
+ override val focusTransitionSpeed: Int = 150,
+ override val visibleWhen: String? = null,
+ /** Stroke width. */
+ val strokeWidth: FloatOrBinding,
+ /** Border color (ARGB). */
+ val color: IntOrBinding,
+ /**
+ * Corner radius in CSS-like syntax:
+ * - "8" = all corners 8
+ * - "8 4" = top-left/bottom-right 8, top-right/bottom-left 4
+ * - "8 4 2" = top-left 8, top-right/bottom-left 4, bottom-right 2
+ * - "8 4 2 1" = top-left 8, top-right 4, bottom-right 2, bottom-left 1
+ */
+ val cornerRadius: String? = null,
+ ) : Layer()
+
+ /**
+ * Text rendering layer.
+ */
+ data class TextLayer(
+ override val id: String? = null,
+ override val position: DimOffset,
+ override val size: DimSize? = null,
+ override val opacity: FloatOrBinding? = null,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val focusOnly: Boolean = false,
+ override val focusTransitionSpeed: Int = 150,
+ override val visibleWhen: String? = null,
+ /** Text content or binding. */
+ val text: StringOrBinding,
+ /** Text color (ARGB). */
+ val color: IntOrBinding,
+ /** Text size. */
+ val textSize: FloatOrBinding,
+ /** Optional max lines for wrapping/truncation. */
+ val maxLines: Int? = null,
+ /** Text alignment: "left", "center", or "right". Defaults to "left". */
+ val textAlign: String = "left",
+ /** Font weight: "normal", "bold", "light", "medium", "semibold", etc. */
+ val fontWeight: String = "normal",
+ /** Font style: "normal" or "italic". */
+ val fontStyle: String = "normal",
+ /** Line height multiplier (e.g., 1.5 = 150% of font size). Null uses default. */
+ val lineHeight: FloatOrBinding? = null,
+ /** Letter spacing in sp (can be negative for tighter spacing). Null uses default. */
+ val letterSpacing: FloatOrBinding? = null,
+ /** Text decoration: "none", "underline", or "lineThrough". */
+ val textDecoration: String = "none",
+ /** Text overflow behavior: "ellipsis", "clip", or "visible". Defaults to "ellipsis". */
+ val overflow: String = "ellipsis",
+ /** Shadow color (ARGB). Null = no shadow. */
+ val shadowColor: IntOrBinding? = null,
+ /** Shadow blur radius. */
+ val shadowRadius: FloatOrBinding? = null,
+ /** Shadow horizontal offset. */
+ val shadowOffsetX: FloatOrBinding? = null,
+ /** Shadow vertical offset. */
+ val shadowOffsetY: FloatOrBinding? = null,
+ ) : Layer()
+
+ /**
+ * Backdrop effect layer (e.g., blur + optional tint behind content).
+ */
+ data class BackdropLayer(
+ override val id: String? = null,
+ override val position: DimOffset,
+ override val size: DimSize? = null,
+ override val opacity: FloatOrBinding? = null,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val focusOnly: Boolean = false,
+ override val focusTransitionSpeed: Int = 150,
+ override val visibleWhen: String? = null,
+ /** Blur radius. */
+ val blurRadius: FloatOrBinding? = null,
+ /** Optional tint color (ARGB). */
+ val tintColor: IntOrBinding? = null,
+ ) : Layer()
+
+ /**
+ * Button layer - renders a clickable button (visual only, card handles click).
+ */
+ data class ButtonLayer(
+ override val id: String? = null,
+ override val position: DimOffset,
+ override val size: DimSize? = null,
+ override val opacity: FloatOrBinding? = null,
+ override val anchor: Anchor = Anchor.TOP_LEFT,
+ override val visibility: Visibility = Visibility.ALWAYS,
+ override val zIndex: Float = 0f,
+ override val declarationOrder: Int = 0,
+ override val focusOnly: Boolean = false,
+ override val focusTransitionSpeed: Int = 150,
+ override val visibleWhen: String? = null,
+ /** Button label text or binding. */
+ val text: StringOrBinding,
+ /** Button background color (ARGB). */
+ val backgroundColor: IntOrBinding,
+ /** Button text color (ARGB). */
+ val textColor: IntOrBinding,
+ /** Text size. */
+ val textSize: FloatOrBinding = FloatOrBinding.Literal(14f),
+ /** Corner radius for button shape. */
+ val cornerRadius: String? = null,
+ /** Border width in pixels, 0 = no border. */
+ val borderWidth: FloatOrBinding? = null,
+ /** Border color (ARGB). */
+ val borderColor: IntOrBinding? = null,
+ /** Font weight: "normal", "bold", "medium", "semibold", etc. */
+ val fontWeight: String = "normal",
+ /** Padding inside button: "vertical horizontal" or single value. */
+ val padding: String? = null,
+ ) : Layer()
+}
diff --git a/app/src/main/java/app/gamenative/theme/model/Layout.kt b/app/src/main/java/app/gamenative/theme/model/Layout.kt
new file mode 100644
index 000000000..a22ffb40e
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Layout.kt
@@ -0,0 +1,267 @@
+package app.gamenative.theme.model
+
+/**
+ * Root theme definition aggregating manifest, variables, cards, and layout elements.
+ * This is a pure data model; no parsing or rendering logic is included here.
+ */
+data class ThemeDefinition(
+ /** Theme manifest with identity and compatibility fields. */
+ val manifest: Manifest,
+ /** Declared variables that can be referenced by bindings. */
+ val variables: List = emptyList(),
+ /** Responsive breakpoints for orientation/size-based variable overrides. */
+ val breakpoints: List = emptyList(),
+ /** Card definitions for rendering individual items (games). */
+ val cards: List = emptyList(),
+ /**
+ * Ordered list of layout elements (fixed containers and content).
+ * Rendered in declaration order unless zIndex overrides it.
+ */
+ val layoutElements: List = emptyList(),
+) {
+ // Convenience accessors for backwards compatibility
+ /** All fixed containers from layout elements. */
+ val fixedContainers: List
+ get() = layoutElements.filterIsInstance().map { it.container }
+
+ /** The main layout node (Grid/Carousel), or null if not present. */
+ val layout: LayoutNode?
+ get() = layoutElements.filterIsInstance().firstOrNull()?.node
+}
+
+/**
+ * A single element in the layout, either a fixed container or content (grid/carousel).
+ * Elements are rendered in declaration order unless zIndex overrides it.
+ */
+sealed class LayoutElement {
+ /** Optional z-index for explicit z-ordering. Null = use declaration order. */
+ abstract val zIndex: Int?
+ /** Declaration order index (set during parsing). Used for stable sorting. */
+ abstract val declarationOrder: Int
+
+ /**
+ * A fixed container with UI elements (backgrounds, buttons, etc.).
+ */
+ data class Fixed(
+ val container: FixedContainer,
+ override val zIndex: Int? = null,
+ override val declarationOrder: Int = 0,
+ ) : LayoutElement()
+
+ /**
+ * The main content area (Grid or Carousel).
+ */
+ data class Content(
+ val node: LayoutNode,
+ override val zIndex: Int? = null,
+ override val declarationOrder: Int = 0,
+ ) : LayoutElement()
+}
+
+/**
+ * A node in the layout tree.
+ */
+sealed class LayoutNode {
+
+ /**
+ * Absolute positioning canvas. Children are placed at explicit positions within [size].
+ */
+ data class Canvas(
+ /** Size of the canvas box. */
+ val size: DimSize,
+ /** Children positioned absolutely within the canvas. */
+ val children: List = emptyList(),
+ ) : LayoutNode()
+
+ /**
+ * Uniform grid layout.
+ */
+ data class Grid(
+ /** Number of columns. Null = adaptive based on cellWidth (recommended). */
+ val columns: Int? = null,
+ /** Number of rows (optional if content is dynamic). */
+ val rows: Int? = null,
+ /** Minimum width of each grid cell. Used for adaptive column calculation. */
+ val cellWidth: Dimension,
+ /** Height of each grid cell. If null, uses aspectRatio or card's canvas height. */
+ val cellHeight: Dimension? = null,
+ /** Aspect ratio (width/height) for automatic cell height calculation. e.g., 2.14 for hero (460:215), 0.67 for capsule (2:3). */
+ val aspectRatio: Float? = null,
+ /** Horizontal spacing between cells. */
+ val hSpacing: Float = 0f,
+ /** Vertical spacing between cells. */
+ val vSpacing: Float = 0f,
+ /** Selection behavior (stationary vs moving). */
+ val selectionMode: SelectionMode = SelectionMode.MOVING,
+ /** Card to use for grid items. */
+ val itemCard: String,
+ /** Content padding from top. */
+ val contentPaddingTop: Float = 0f,
+ /** Content padding from bottom. */
+ val contentPaddingBottom: Float = 0f,
+ /** Content padding from start (left in LTR). */
+ val contentPaddingStart: Float = 0f,
+ /** Content padding from end (right in LTR). */
+ val contentPaddingEnd: Float = 0f,
+ /** Optional separator rendered between items. Contains static layers (no game bindings). */
+ val separator: GridSeparator? = null,
+ /** Vertical alignment of items within each cell. */
+ val verticalAlign: VerticalAlign = VerticalAlign.TOP,
+ /** Navigation ID for this grid (used by other elements to navigate to it). */
+ val navigationId: String? = null,
+ /** Navigation target when pressing up at the edge. */
+ val navigateUp: String? = null,
+ /** Navigation target when pressing down at the edge. */
+ val navigateDown: String? = null,
+ /** Navigation target when pressing left at the edge. */
+ val navigateLeft: String? = null,
+ /** Navigation target when pressing right at the edge. */
+ val navigateRight: String? = null,
+ /** Focus border width in dp. */
+ val highlightBorderWidth: Float = 3f,
+ /** Focus border corner radius in dp. */
+ val highlightCornerRadius: Float = 8f,
+ ) : LayoutNode()
+
+ /**
+ * Carousel (row or column) layout.
+ */
+ data class Carousel(
+ /** Scroll direction for the carousel. */
+ val direction: Direction = Direction.RIGHT,
+ /** Scroll orientation: horizontal or vertical. */
+ val orientation: CarouselOrientation = CarouselOrientation.HORIZONTAL,
+ /** Size of each item in the carousel. */
+ val itemSize: DimSize,
+ /** Spacing between items. */
+ val itemSpacing: Float = 0f,
+ /** Selection behavior (stationary vs moving). */
+ val selectionMode: SelectionMode = SelectionMode.STATIONARY,
+ /** Card to use for carousel items. */
+ val itemCard: String,
+ /** Optional number of items visible at once (page size). */
+ val pageSize: Int? = null,
+ /** Whether the carousel centers on the focused item with snap behavior. */
+ val centerFocus: Boolean = false,
+ /** Scale factor for the focused item (1.0 = no scaling). */
+ val focusedScale: Float = 1.0f,
+ /** Alpha/opacity for unfocused items (0.0-1.0, default 1.0 = no fade). */
+ val unfocusedAlpha: Float = 1.0f,
+ /** Vertical alignment within parent container (for horizontal carousels). */
+ val verticalAlign: VerticalAlign = VerticalAlign.TOP,
+ /** Vertical offset from the aligned position (positive = down, for horizontal carousels). */
+ val verticalOffset: Dimension = Dimension.Px(0f),
+ /** Horizontal alignment within parent container (for vertical carousels). */
+ val horizontalAlign: HorizontalAlign = HorizontalAlign.START,
+ /** Horizontal offset from the aligned position (positive = right, for vertical carousels). */
+ val horizontalOffset: Dimension = Dimension.Px(0f),
+ /** X offset applied to the focused item (positive = right). */
+ val focusedOffsetX: Float = 0f,
+ /** Y offset applied to the focused item (positive = down). */
+ val focusedOffsetY: Float = 0f,
+ /** Extra spacing around the focused item to account for scaling (added to itemSpacing). */
+ val focusedSpacing: Float = 0f,
+ /** Additional offset for items before (left of / above) the focused item. Useful for asymmetric layouts. */
+ val beforeFocusOffset: Float = 0f,
+ /** Background image binding for focused item (e.g., "@{game.hero}"). Null = no background. */
+ val focusedBackground: StringOrBinding? = null,
+ /** Opacity for the focused background image (0.0-1.0). */
+ val backgroundOpacity: Float = 0.3f,
+ /** Duration in ms for background crossfade transition. */
+ val backgroundTransitionSpeed: Int = 400,
+ /** Navigation ID for this carousel (used by other elements to navigate to it). */
+ val navigationId: String? = null,
+ /** Navigation target when pressing up at the edge. */
+ val navigateUp: String? = null,
+ /** Navigation target when pressing down at the edge. */
+ val navigateDown: String? = null,
+ /** Navigation target when pressing left at the edge. */
+ val navigateLeft: String? = null,
+ /** Navigation target when pressing right at the edge. */
+ val navigateRight: String? = null,
+ /** Focus border width in dp. */
+ val highlightBorderWidth: Float = 3f,
+ /** Focus border corner radius in dp. */
+ val highlightCornerRadius: Float = 8f,
+ ) : LayoutNode()
+}
+
+/**
+ * Vertical alignment options for layout elements.
+ */
+enum class VerticalAlign {
+ TOP,
+ CENTER,
+ BOTTOM;
+
+ companion object {
+ fun fromString(value: String?): VerticalAlign = when (value?.lowercase()) {
+ "center" -> CENTER
+ "bottom" -> BOTTOM
+ else -> TOP
+ }
+ }
+}
+
+/**
+ * Horizontal alignment options for layout elements.
+ */
+enum class HorizontalAlign {
+ START,
+ CENTER,
+ END;
+
+ companion object {
+ fun fromString(value: String?): HorizontalAlign = when (value?.lowercase()) {
+ "center" -> CENTER
+ "end", "right" -> END
+ else -> START
+ }
+ }
+}
+
+/**
+ * Carousel scroll orientation.
+ */
+enum class CarouselOrientation {
+ HORIZONTAL,
+ VERTICAL;
+
+ companion object {
+ fun fromString(value: String?): CarouselOrientation = when (value?.lowercase()) {
+ "vertical" -> VERTICAL
+ else -> HORIZONTAL
+ }
+ }
+}
+
+/**
+ * A child placed on a [LayoutNode.Canvas].
+ */
+data class CanvasChild(
+ /** Card to render at this position. */
+ val cardId: String,
+ /** Absolute position within the parent canvas. */
+ val position: DimOffset,
+ /** Optional explicit size; otherwise the card's canvas size is used. */
+ val size: DimSize? = null,
+)
+
+/**
+ * A separator rendered between grid items.
+ * Contains static layers (rect, image, text) without game bindings.
+ */
+data class GridSeparator(
+ /** Height of the separator content area. */
+ val height: Dimension,
+ /** Layers to render in the separator (rect, image, text only - no game bindings). */
+ val layers: List = emptyList(),
+ /** Margin from top of separator. */
+ val marginTop: Float = 0f,
+ /** Margin from bottom of separator. */
+ val marginBottom: Float = 0f,
+ /** Margin from start (left in LTR). */
+ val marginStart: Float = 0f,
+ /** Margin from end (right in LTR). */
+ val marginEnd: Float = 0f,
+)
diff --git a/app/src/main/java/app/gamenative/theme/model/Manifest.kt b/app/src/main/java/app/gamenative/theme/model/Manifest.kt
new file mode 100644
index 000000000..0fd0c5f3e
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Manifest.kt
@@ -0,0 +1,24 @@
+package app.gamenative.theme.model
+
+/**
+ * Theme manifest describing compatibility and identification information.
+ */
+data class Manifest(
+ /** Unique identifier of the theme (folder name friendly). */
+ val id: String,
+ /** Human readable version of the theme (semantic string, e.g., "1.0.0"). */
+ val version: String,
+ /**
+ * Theme engine version constraint this theme targets.
+ * Supports Composer-like constraints:
+ * - "1.0.0" - exact match
+ * - "1.*" or "1.x" - any version with major version 1
+ * - "^1.0.0" - >=1.0.0 and <2.0.0
+ * - "~1.2.0" - >=1.2.0 and <1.3.0
+ */
+ val engineVersion: String,
+ /** Minimum supported app version (semantic string, e.g., "1.2.0"). */
+ val minAppVersion: String,
+ /** Optional maximum supported app version (semantic string). */
+ val maxAppVersion: String? = null,
+)
diff --git a/app/src/main/java/app/gamenative/theme/model/Media.kt b/app/src/main/java/app/gamenative/theme/model/Media.kt
new file mode 100644
index 000000000..2ea7f7323
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Media.kt
@@ -0,0 +1,37 @@
+package app.gamenative.theme.model
+
+/**
+ * Describes a media source used by layers (image/video).
+ */
+sealed class MediaSource {
+ /**
+ * An image source, typically resolved to a URI by the AssetResolver at render time.
+ * The value may be a literal string or a binding (e.g., `game.capsule`).
+ */
+ data class Image(
+ /** Logical path or URI for the image. */
+ val src: StringOrBinding,
+ /** Optional fallback image to use if [src] fails to load. */
+ val fallback: StringOrBinding? = null,
+ ) : MediaSource()
+
+ /**
+ * A video source with safe defaults and options to control playback policy.
+ */
+ data class Video(
+ /** Logical path or URI for the video. */
+ val src: StringOrBinding,
+ /** Poster image shown before the first frame or when paused. */
+ val poster: StringOrBinding? = null,
+ /** Whether video plays automatically when eligible (focused/selected and visible). */
+ val autoplay: Boolean = false,
+ /** Whether the video loops when it reaches the end. */
+ val loop: Boolean = true,
+ /** Whether audio is muted by default. */
+ val muted: Boolean = true,
+ /** Preload behavior for buffering data. */
+ val preload: VideoPreloadPolicy = VideoPreloadPolicy.METADATA,
+ /** Optional fallback image when video cannot play. */
+ val fallbackImage: StringOrBinding? = null,
+ ) : MediaSource()
+}
diff --git a/app/src/main/java/app/gamenative/theme/model/Source.kt b/app/src/main/java/app/gamenative/theme/model/Source.kt
new file mode 100644
index 000000000..8f2e64dd3
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Source.kt
@@ -0,0 +1,77 @@
+package app.gamenative.theme.model
+
+/**
+ * Source location information for better diagnostics in parsing/validation.
+ */
+data class SourceLoc(
+ /** Full file path on disk or in assets from which this node originated. */
+ val filePath: String,
+ /** 1-based line number within the source file, if available. */
+ val line: Int? = null,
+ /** 1-based column number within the source file, if available. */
+ val column: Int? = null,
+)
+
+/**
+ * Minimal XML-structured node used by the ThemeLoader to represent merged XML trees
+ * (with includes expanded) while preserving source locations.
+ */
+data class XmlNode(
+ /** Tag name of the element. */
+ val name: String,
+ /** Attributes as parsed on this element. */
+ val attributes: Map = emptyMap(),
+ /** Ordered child elements (text content is not significant for our schema). */
+ val children: List = emptyList(),
+ /** Optional text content if needed for leaf nodes. */
+ val text: String? = null,
+ /** Where this node came from in the original source. */
+ val source: SourceLoc? = null,
+)
+
+/**
+ * Resulting merged theme tree produced by ThemeLoader step.
+ * This is the artifact validated by ThemeValidator in the next step,
+ * and later mapped into the runtime ThemeDefinition.
+ */
+data class ThemeTree(
+ /** Directory path (root folder of the theme). */
+ val rootDir: String,
+ /** Parsed manifest entry values that guide loading (e.g., selected theme.xml and variables.xml paths). */
+ val manifestEntry: ManifestEntry?,
+ /** Fully merged theme XML as an XmlNode tree with includes expanded. */
+ val themeXml: XmlNode,
+ /** Merged variables from external and inline sources; last-writer wins semantics. */
+ val variables: Map = emptyMap(),
+ /** Responsive breakpoints for orientation/size-based variable overrides. */
+ val breakpoints: List = emptyList(),
+)
+
+/**
+ * Manifest entry directing the loader which files to use.
+ */
+data class ManifestEntry(
+ /** Relative path to theme.xml within the theme folder. */
+ val themePath: String,
+ /** Optional relative path to variables.xml within the theme folder. */
+ val variablesPath: String? = null,
+ /** Source location for the entry for diagnostics. */
+ val source: SourceLoc? = null,
+)
+
+/**
+ * A recoverable error encountered during loading; never throws to caller.
+ */
+data class ThemeLoadError(
+ /** Stable error code for programmatic handling. */
+ val code: String,
+ /** Human-readable message (dev facing). */
+ val message: String,
+ /** Optional source location for precise diagnostics. */
+ val source: SourceLoc? = null,
+)
+
+sealed class ThemeLoadResult {
+ data class Success(val tree: ThemeTree): ThemeLoadResult()
+ data class Failure(val errors: List): ThemeLoadResult()
+}
diff --git a/app/src/main/java/app/gamenative/theme/model/Types.kt b/app/src/main/java/app/gamenative/theme/model/Types.kt
new file mode 100644
index 000000000..6f51462e7
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/model/Types.kt
@@ -0,0 +1,35 @@
+package app.gamenative.theme.model
+
+/**
+ * Measurement unit for positions and sizes.
+ *
+ * - Px: absolute pixels.
+ * - RelW: fraction of container width (0..1), e.g., 0.5 = 50% of width.
+ * - RelH: fraction of container height (0..1), e.g., 0.5 = 50% of height.
+ */
+sealed class Dimension {
+ /** Absolute pixels. */
+ data class Px(val value: Float) : Dimension()
+ /** Relative to container width [0..1]. */
+ data class RelW(val fraction: Float) : Dimension()
+ /** Relative to container height [0..1]. */
+ data class RelH(val fraction: Float) : Dimension()
+ /** Unspecified - use default or intrinsic size. */
+ data object Unspecified : Dimension()
+}
+
+/** Simple 2D size in [Dimension] units. */
+data class DimSize(
+ /** Width dimension. */
+ val width: Dimension,
+ /** Height dimension. */
+ val height: Dimension,
+)
+
+/** Simple 2D offset in [Dimension] units. */
+data class DimOffset(
+ /** X position from the left. */
+ val x: Dimension,
+ /** Y position from the top. */
+ val y: Dimension,
+)
diff --git a/app/src/main/java/app/gamenative/theme/runtime/BreakpointResolver.kt b/app/src/main/java/app/gamenative/theme/runtime/BreakpointResolver.kt
new file mode 100644
index 000000000..30567f621
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/BreakpointResolver.kt
@@ -0,0 +1,135 @@
+package app.gamenative.theme.runtime
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalConfiguration
+import app.gamenative.theme.ThemeManager
+import app.gamenative.theme.model.Breakpoint
+import app.gamenative.theme.model.Visibility
+
+/**
+ * Runtime breakpoint resolver for responsive theme layouts.
+ *
+ * Resolves variables based on current screen orientation and width,
+ * applying matching breakpoints in order (CSS cascade behavior).
+ *
+ * Usage:
+ * ```kotlin
+ * val resolvedVariables = rememberResolvedVariables(
+ * baseVariables = theme.variables,
+ * breakpoints = theme.breakpoints
+ * )
+ * ```
+ */
+object BreakpointResolver {
+
+ /**
+ * Composable that resolves variables based on current screen configuration.
+ * Automatically recomposes when orientation or screen size changes.
+ * Uses MainActivity's configuration state instead of LocalConfiguration.
+ *
+ * @param baseVariables Default variable values from theme
+ * @param breakpoints List of breakpoints that may override variables
+ * @return Map of resolved variable names to values
+ */
+ @Composable
+ fun rememberResolvedVariables(
+ baseVariables: Map,
+ breakpoints: List
+ ): Map {
+ val orientation = app.gamenative.MainActivity.currentOrientation.value
+ val screenWidthDp = app.gamenative.MainActivity.currentScreenWidthDp.value
+ val isPortrait = orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
+
+ return remember(isPortrait, screenWidthDp, baseVariables, breakpoints) {
+ VariableResolver.resolveWithBreakpoints(baseVariables, breakpoints, isPortrait, screenWidthDp)
+ }
+ }
+
+ /**
+ * Composable that returns whether the current screen is in portrait mode.
+ * Uses MainActivity's configuration state instead of LocalConfiguration.
+ */
+ @Composable
+ fun rememberIsPortrait(): Boolean {
+ val orientation = app.gamenative.MainActivity.currentOrientation.value
+ return remember(orientation) {
+ orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
+ }
+ }
+
+ /**
+ * Check if an element with the given visibility should be shown.
+ */
+ @Composable
+ fun shouldShowElement(visibility: Visibility): Boolean {
+ val isPortrait = rememberIsPortrait()
+ return visibility.isVisible(isPortrait)
+ }
+}
+
+/**
+ * Convenience composable to resolve variables from a theme tree.
+ */
+@Composable
+fun rememberResolvedVariables(
+ baseVariables: Map,
+ breakpoints: List
+): Map = BreakpointResolver.rememberResolvedVariables(baseVariables, breakpoints)
+
+/**
+ * Convenience composable to check if current orientation is portrait.
+ */
+@Composable
+fun rememberIsPortrait(): Boolean = BreakpointResolver.rememberIsPortrait()
+
+/**
+ * Convenience composable to check if an element should be visible.
+ */
+@Composable
+fun shouldShowElement(visibility: Visibility): Boolean = BreakpointResolver.shouldShowElement(visibility)
+
+/**
+ * Effect that triggers theme remapping when orientation changes.
+ * Place this in any screen that uses the theme engine.
+ *
+ * This automatically triggers ThemeManager.remapForOrientation() when the
+ * screen configuration changes, ensuring breakpoint-aware variable resolution.
+ *
+ * IMPORTANT: This uses MainActivity's configuration state instead of LocalConfiguration
+ * because android:configChanges prevents LocalConfiguration from updating.
+ */
+@Composable
+fun OrientationAwareThemeEffect() {
+ // Use MainActivity's configuration state instead of LocalConfiguration
+ // LocalConfiguration doesn't update when android:configChanges is set
+ val orientation = app.gamenative.MainActivity.currentOrientation.value
+ val screenWidthDp = app.gamenative.MainActivity.currentScreenWidthDp.value
+ val isPortrait = orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
+
+ // Use remember to trigger remapping synchronously during composition
+ // This ensures theme values are updated BEFORE the UI renders
+ remember(isPortrait, screenWidthDp) {
+ if (ThemeManager.hasBreakpoints()) {
+ ThemeManager.remapForOrientation(isPortrait, screenWidthDp)
+ }
+ Unit
+ }
+}
+
+/**
+ * Returns a stable key that changes when orientation changes.
+ * Use this with key() to force recomposition of themed content.
+ * Uses MainActivity's configuration state instead of LocalConfiguration.
+ */
+@Composable
+fun rememberOrientationKey(): String {
+ val orientation = app.gamenative.MainActivity.currentOrientation.value
+ val screenWidthDp = app.gamenative.MainActivity.currentScreenWidthDp.value
+ val isPortrait = orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
+ return remember(isPortrait, screenWidthDp) {
+ "$isPortrait-$screenWidthDp"
+ }
+}
+
diff --git a/app/src/main/java/app/gamenative/theme/runtime/FixedElementRenderer.kt b/app/src/main/java/app/gamenative/theme/runtime/FixedElementRenderer.kt
new file mode 100644
index 000000000..ce13afd75
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/FixedElementRenderer.kt
@@ -0,0 +1,1239 @@
+package app.gamenative.theme.runtime
+
+import android.annotation.SuppressLint
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.delay
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.annotation.OptIn
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.PlayerView
+import app.gamenative.PrefManager
+import app.gamenative.R
+import app.gamenative.service.DownloadService
+import app.gamenative.theme.model.Anchor
+import app.gamenative.theme.model.Dimension
+import app.gamenative.theme.model.DimOffset
+import app.gamenative.theme.model.DimSize
+import app.gamenative.theme.model.FixedContainer
+import app.gamenative.theme.model.FixedElement
+import app.gamenative.theme.model.Visibility
+import app.gamenative.ui.data.LibraryState
+import app.gamenative.ui.enums.AppFilter
+
+/**
+ * Data class to hold all the callbacks and state needed by fixed elements.
+ */
+data class FixedElementCallbacks(
+ val onNavigateRoute: (String) -> Unit,
+ val onLogout: () -> Unit,
+ val onGoOnline: () -> Unit,
+ val onFilterClick: () -> Unit,
+ val onAddClick: () -> Unit,
+ val onSearchQuery: (String) -> Unit,
+ val isOffline: Boolean,
+ val filterExpanded: Boolean,
+ val isSearching: Boolean,
+)
+
+/**
+ * Data class holding highlight styling configuration for controller navigation.
+ */
+data class HighlightStyle(
+ val color: Color,
+ val opacity: Float,
+ val borderWidth: Dp,
+ val transitionSpeed: Int,
+)
+
+/**
+ * Extract highlight style from a FixedElement using its highlight properties.
+ */
+@Composable
+private fun FixedElement.toHighlightStyle(): HighlightStyle = HighlightStyle(
+ color = highlightColor?.let { Color(it) } ?: MaterialTheme.colorScheme.primary,
+ opacity = highlightOpacity,
+ borderWidth = highlightBorderWidth.dp,
+ transitionSpeed = highlightTransitionSpeed,
+)
+
+/**
+ * A composable wrapper that adds animated highlight border indication for controller navigation.
+ * Used only for themed fixed elements (not default layout).
+ * Uses hasFocus to detect focus on any descendant (like buttons inside).
+ *
+ * When a SpatialFocusManager is available (via LocalSpatialFocusManager), this box
+ * registers itself for spatial navigation and handles D-pad key events.
+ *
+ * @param id Unique identifier for spatial navigation registration
+ * @param highlightStyle Visual styling for the highlight border
+ * @param cornerRadius Border corner radius
+ * @param navigationLinks Optional explicit navigation overrides
+ * @param modifier Additional modifiers
+ * @param content The content to render inside the box
+ */
+@Composable
+private fun HighlightableBox(
+ id: String,
+ highlightStyle: HighlightStyle,
+ cornerRadius: Dp,
+ navigationLinks: SpatialFocusManager.NavigationLinks = SpatialFocusManager.NavigationLinks(),
+ modifier: Modifier = Modifier,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ val spatialFocusManager = LocalSpatialFocusManager.current
+ val focusRequester = remember { FocusRequester() }
+
+ // Use hasFocus to detect focus on this element OR any descendant
+ var hasFocus by remember { mutableStateOf(false) }
+
+ val highlightAlpha by animateFloatAsState(
+ targetValue = if (hasFocus) highlightStyle.opacity else 0f,
+ animationSpec = tween(durationMillis = highlightStyle.transitionSpeed),
+ label = "highlightBorderAlpha"
+ )
+
+ // Use gradient brush for default focus styling (matching grid behavior)
+ val gradientBrush = Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.tertiary.copy(alpha = highlightAlpha),
+ MaterialTheme.colorScheme.primary.copy(alpha = highlightAlpha),
+ )
+ )
+
+ Box(
+ modifier = modifier
+ .focusRequester(focusRequester)
+ // Register with spatial focus manager when positioned
+ .onGloballyPositioned { coordinates ->
+ spatialFocusManager?.register(
+ id = id,
+ bounds = coordinates.boundsInRoot(),
+ focusRequester = focusRequester,
+ navigationLinks = navigationLinks
+ )
+ }
+ // Track focus on this element or any child for highlight border
+ .onFocusChanged { focusState ->
+ hasFocus = focusState.hasFocus
+ if (focusState.hasFocus) {
+ spatialFocusManager?.setFocused(id)
+ }
+ }
+ // Handle D-pad navigation using spatial focus manager
+ .onKeyEvent { keyEvent ->
+ if (keyEvent.type == KeyEventType.KeyDown && spatialFocusManager != null) {
+ val direction = when (keyEvent.key) {
+ Key.DirectionUp -> SpatialFocusManager.Direction.UP
+ Key.DirectionDown -> SpatialFocusManager.Direction.DOWN
+ Key.DirectionLeft -> SpatialFocusManager.Direction.LEFT
+ Key.DirectionRight -> SpatialFocusManager.Direction.RIGHT
+ else -> null
+ }
+ if (direction != null) {
+ spatialFocusManager.navigateInDirection(id, direction)
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ }
+ .then(
+ if (highlightAlpha > 0f) {
+ Modifier.border(
+ width = highlightStyle.borderWidth,
+ brush = gradientBrush,
+ shape = RoundedCornerShape(cornerRadius)
+ )
+ } else {
+ Modifier
+ }
+ ),
+ content = content
+ )
+}
+
+/**
+ * Create NavigationLinks from a FixedElement's navigation properties.
+ */
+private fun FixedElement.toNavigationLinks() = SpatialFocusManager.NavigationLinks(
+ up = navigateUp,
+ down = navigateDown,
+ left = navigateLeft,
+ right = navigateRight,
+)
+
+/**
+ * Renders fixed UI elements from theme configuration.
+ * Falls back to default positioning if no fixed containers are defined.
+ *
+ * Fixed containers are rendered in their declaration order from the section.
+ * Z-ordering is controlled by the caller (LibraryScreen) which iterates through
+ * layout elements in sorted z-order.
+ */
+@Composable
+fun BoxScope.RenderFixedElements(
+ fixedContainers: List,
+ state: LibraryState,
+ listState: LazyGridState,
+ themeName: String,
+ callbacks: FixedElementCallbacks,
+ accountButtonContent: @Composable (iconSize: Dp) -> Unit,
+ searchBarContent: @Composable (app.gamenative.ui.screen.library.components.SearchBarStyle) -> Unit,
+ themeRootDir: String? = null,
+) {
+ if (fixedContainers.isEmpty()) {
+ // Fallback to default positioning when no fixed containers are defined
+ RenderDefaultFixedElements(
+ state = state,
+ listState = listState,
+ themeName = themeName,
+ callbacks = callbacks,
+ accountButtonContent = accountButtonContent,
+ searchBarContent = searchBarContent,
+ )
+ return
+ }
+
+ // Determine current orientation for visibility filtering (centralized)
+ val isPortrait = rememberIsPortrait()
+
+ // Render all containers in declaration order
+ fixedContainers.forEach { container ->
+ // Check container visibility first - skip entire container if not visible
+ if (!container.visibility.isVisible(isPortrait)) return@forEach
+
+ // Filter elements by visibility
+ // Elements inherit container's visibility unless they specify their own
+ val visibleElements = container.elements
+ .filter { element -> element.visibility.isVisible(isPortrait) }
+ // Sort by zIndex first (default 0), then by declaration order for stable z-ordering
+ .sortedWith(compareBy { it.zIndex }.thenBy { it.declarationOrder })
+
+ // Skip container if no elements are visible
+ if (visibleElements.isEmpty()) return@forEach
+
+ // Determine container alignment based on id (topBar at top, bottomBar at bottom)
+ val containerAlignment = when {
+ container.id.contains("top", ignoreCase = true) -> Alignment.TopCenter
+ container.id.contains("bottom", ignoreCase = true) -> Alignment.BottomCenter
+ else -> Alignment.TopCenter
+ }
+
+ // Parse CSS-style padding: "all" or "top right bottom left" (1-4 values)
+ val paddingValues = container.padding?.let { parseCssPadding(it) } ?: PaddingValues(0.dp)
+ val cornerRadius = container.cornerRadius.dp
+ val shape = if (cornerRadius > 0.dp) RoundedCornerShape(cornerRadius) else RectangleShape
+
+ // Render container background if specified
+ if (container.backgroundColor != null) {
+ Box(
+ modifier = Modifier
+ .align(containerAlignment)
+ .fillMaxWidth()
+ .height(container.height?.dp ?: 80.dp)
+ .clip(shape)
+ .background(Color(container.backgroundColor))
+ .padding(paddingValues)
+ )
+ }
+
+ // Render visible elements with IDs for spatial focus navigation
+ visibleElements.forEachIndexed { index, element ->
+ // Use custom navigationId if set, otherwise generate a unique ID
+ val elementId = getNavigationId(container.id, element, index)
+ RenderFixedElement(
+ element = element,
+ elementId = elementId,
+ state = state,
+ listState = listState,
+ themeName = themeName,
+ callbacks = callbacks,
+ accountButtonContent = accountButtonContent,
+ searchBarContent = searchBarContent,
+ themeRootDir = themeRootDir,
+ )
+ }
+ }
+}
+
+/**
+ * Get the navigation ID for a fixed element.
+ * Uses the custom navigationId if set, otherwise generates a unique ID
+ * based on container ID, element type, and index.
+ */
+private fun getNavigationId(containerId: String, element: FixedElement, index: Int): String {
+ // Use custom navigationId if set by theme creator
+ element.navigationId?.let { return it }
+
+ // Otherwise generate a unique ID
+ val typePrefix = when (element) {
+ is FixedElement.Header -> "header"
+ is FixedElement.SearchBar -> "search-bar"
+ is FixedElement.ProfileButton -> "profile-button"
+ is FixedElement.FilterButton -> "filter-button"
+ is FixedElement.AddButton -> "add-button"
+ is FixedElement.Image -> "image"
+ is FixedElement.Video -> "video"
+ is FixedElement.Rect -> "rect"
+ is FixedElement.Text -> "text"
+ is FixedElement.Shadow -> "shadow"
+ is FixedElement.Border -> "border"
+ is FixedElement.Backdrop -> "backdrop"
+ is FixedElement.SystemTime -> "system-time"
+ }
+ return "$containerId-$typePrefix-$index"
+}
+
+@SuppressLint("UnusedBoxWithConstraintsScope")
+@Composable
+private fun BoxScope.RenderFixedElement(
+ element: FixedElement,
+ elementId: String,
+ state: LibraryState,
+ listState: LazyGridState,
+ themeName: String,
+ callbacks: FixedElementCallbacks,
+ accountButtonContent: @Composable (iconSize: Dp) -> Unit,
+ searchBarContent: @Composable (app.gamenative.ui.screen.library.components.SearchBarStyle) -> Unit,
+ themeRootDir: String? = null,
+) {
+ // Use BoxWithConstraints to get parent dimensions for relative size calculations
+ BoxWithConstraints(modifier = Modifier.matchParentSize()) {
+ val parentWidth = maxWidth
+ val parentHeight = maxHeight
+ val parentSize = DpSize(parentWidth, parentHeight)
+
+ // Calculate raw position
+ val rawX = dimToDp(element.position.x, parentWidth, parentHeight)
+ val rawY = dimToDp(element.position.y, parentWidth, parentHeight)
+
+ // For CSS-like elements (buttons, header, searchbar, systemtime, etc.) use CSS positioning
+ // For decorative elements (rect, text, shadow, border, backdrop, image, video) use absolute positioning
+ val useCssPositioning = element is FixedElement.Header ||
+ element is FixedElement.SearchBar ||
+ element is FixedElement.ProfileButton ||
+ element is FixedElement.FilterButton ||
+ element is FixedElement.AddButton ||
+ element is FixedElement.SystemTime
+
+ val alignment = if (useCssPositioning) element.anchor.toComposeAlignment() else Alignment.TopStart
+ val (offsetX, offsetY) = if (useCssPositioning) {
+ calculateCssLikeOffset(rawX, rawY, element.anchor)
+ } else {
+ // For absolute positioning, we'll calculate per-element based on their size
+ Pair(rawX, rawY) // Placeholder, will be overridden per element
+ }
+
+ when (element) {
+ is FixedElement.Header -> {
+ // Calculate installed count like LibraryListPane does
+ val installedCount = remember(
+ state.appInfoSortType,
+ state.showSteamInLibrary,
+ state.showCustomGamesInLibrary,
+ state.totalAppsInFilter
+ ) {
+ if (state.appInfoSortType.contains(AppFilter.INSTALLED)) {
+ state.totalAppsInFilter
+ } else {
+ val steamCount = if (state.showSteamInLibrary) {
+ DownloadService.getDownloadDirectoryApps().count()
+ } else 0
+ val customGameCount = if (state.showCustomGamesInLibrary) {
+ PrefManager.customGamesCount
+ } else 0
+ steamCount + customGameCount
+ }
+ }
+
+ // Header styling from theme
+ val bgColor = element.backgroundColor?.let { Color(it) }
+ val cornerRadius = element.cornerRadius.dp
+ val padding = element.padding.dp
+ val textColor = Color(element.textColor)
+ val textSizeSp = element.textSize.sp
+ val fontWeight = SharedElementRenderers.parseFontWeight(element.fontWeight)
+
+ // Text shadow
+ val textShadow = element.textShadowColor?.let {
+ Shadow(
+ color = Color(it),
+ offset = Offset(element.textShadowOffsetX, element.textShadowOffsetY),
+ blurRadius = element.textShadowRadius
+ )
+ }
+
+ // Calculate size if specified
+ val sizeModifier = element.size?.let { size ->
+ Modifier.size(
+ width = dimToDp(size.width, parentWidth, parentHeight),
+ height = dimToDp(size.height, parentWidth, parentHeight)
+ )
+ } ?: Modifier
+
+ Column(
+ modifier = Modifier
+ .align(alignment)
+ .offset(x = offsetX, y = offsetY)
+ .then(sizeModifier)
+ .then(
+ if (bgColor != null) {
+ Modifier
+ .clip(RoundedCornerShape(cornerRadius))
+ .background(bgColor)
+ } else Modifier
+ )
+ .padding(padding)
+ ) {
+ if (element.showAppName) {
+ Text(
+ text = "GameNative",
+ style = MaterialTheme.typography.headlineSmall.copy(
+ fontWeight = fontWeight,
+ brush = Brush.horizontalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primary,
+ MaterialTheme.colorScheme.tertiary
+ )
+ ),
+ shadow = textShadow
+ )
+ )
+ }
+ if (element.showThemeName) {
+ Text(
+ text = "Theme: $themeName",
+ style = MaterialTheme.typography.bodySmall.copy(fontSize = textSizeSp, shadow = textShadow),
+ color = textColor.copy(alpha = 0.7f)
+ )
+ }
+ if (element.showGameCount) {
+ Text(
+ text = stringResource(
+ R.string.library_game_count,
+ state.totalAppsInFilter,
+ installedCount
+ ),
+ style = MaterialTheme.typography.bodyMedium.copy(fontSize = textSizeSp, shadow = textShadow),
+ color = textColor.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+
+ is FixedElement.SearchBar -> {
+ val bgColor = element.backgroundColor?.let { Color(it) }
+ val radius = element.borderRadius
+ val expandedWidth = dimToDp(element.size.width, parentWidth, parentHeight)
+ val highlightStyle = element.toHighlightStyle()
+
+ // Check if anchor is on the right side
+ val isAnchorRight = element.anchor == Anchor.TOP_RIGHT ||
+ element.anchor == Anchor.CENTER_RIGHT ||
+ element.anchor == Anchor.BOTTOM_RIGHT
+
+ // Create style from theme element with highlight properties and navigation links
+ val textColor = element.textColor?.let { Color(it) }
+ val searchHeight = dimToDp(element.size.height, parentWidth, parentHeight)
+ val searchStyle = app.gamenative.ui.screen.library.components.SearchBarStyle(
+ backgroundColor = bgColor,
+ textColor = textColor,
+ borderRadius = radius,
+ collapsible = element.collapsible,
+ anchorRight = isAnchorRight,
+ expandDirection = element.expandDirection,
+ expandedWidth = expandedWidth,
+ height = searchHeight,
+ highlightColor = highlightStyle.color,
+ highlightOpacity = highlightStyle.opacity,
+ highlightBorderWidth = highlightStyle.borderWidth,
+ highlightTransitionSpeed = highlightStyle.transitionSpeed,
+ navigationId = elementId,
+ navigateUp = element.navigateUp,
+ navigateDown = element.navigateDown,
+ navigateLeft = element.navigateLeft,
+ navigateRight = element.navigateRight,
+ textShadowColor = element.textShadowColor?.let { Color(it) },
+ textShadowRadius = element.textShadowRadius,
+ textShadowOffsetX = element.textShadowOffsetX,
+ textShadowOffsetY = element.textShadowOffsetY,
+ )
+
+ Box(
+ modifier = Modifier
+ .align(alignment)
+ .offset(x = offsetX, y = offsetY)
+ .height(dimToDp(element.size.height, parentWidth, parentHeight))
+ ) {
+ searchBarContent(searchStyle)
+ }
+ }
+
+ is FixedElement.ProfileButton -> {
+ val bgColor = element.backgroundColor?.let { Color(it) }
+ ?: MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+ val radius = element.cornerRadius.dp
+ val buttonSize = element.size.dp
+ val buttonPadding = element.padding.dp
+ val iconSize = element.iconSize.dp
+ val highlightStyle = element.toHighlightStyle()
+ val navigationLinks = element.toNavigationLinks()
+
+ // Use a larger size for the HighlightableBox to account for the border
+ val totalSize = buttonSize + highlightStyle.borderWidth * 2
+ val borderOffset = highlightStyle.borderWidth
+
+ // Adjust offset based on anchor direction so border expands outward
+ val adjustedOffsetX = when (element.anchor) {
+ Anchor.TOP_RIGHT, Anchor.CENTER_RIGHT, Anchor.BOTTOM_RIGHT -> offsetX + borderOffset
+ Anchor.TOP_LEFT, Anchor.CENTER_LEFT, Anchor.BOTTOM_LEFT -> offsetX - borderOffset
+ else -> offsetX // Center anchors: no X adjustment
+ }
+ val adjustedOffsetY = when (element.anchor) {
+ Anchor.BOTTOM_LEFT, Anchor.BOTTOM_CENTER, Anchor.BOTTOM_RIGHT -> offsetY + borderOffset
+ Anchor.TOP_LEFT, Anchor.TOP_CENTER, Anchor.TOP_RIGHT -> offsetY - borderOffset
+ else -> offsetY // Center anchors: no Y adjustment
+ }
+
+ HighlightableBox(
+ id = elementId,
+ highlightStyle = highlightStyle,
+ cornerRadius = radius + borderOffset,
+ navigationLinks = navigationLinks,
+ modifier = Modifier
+ .align(alignment)
+ .offset(x = adjustedOffsetX, y = adjustedOffsetY)
+ .size(totalSize),
+ ) {
+ // Inner box with the actual background and content
+ // Use padding to leave space for the border, then apply background
+ Box(
+ modifier = Modifier
+ .padding(borderOffset)
+ .fillMaxSize()
+ .clip(RoundedCornerShape(radius))
+ .background(bgColor)
+ .padding(buttonPadding),
+ contentAlignment = Alignment.Center
+ ) {
+ accountButtonContent(iconSize)
+ }
+ }
+ }
+
+ is FixedElement.FilterButton -> {
+ if (!callbacks.isSearching) {
+ val highlightStyle = element.toHighlightStyle()
+ val navigationLinks = element.toNavigationLinks()
+ val buttonSize = element.size.dp
+ val iconSize = element.iconSize.dp
+ val bgColor = element.backgroundColor?.let { Color(it) } ?: MaterialTheme.colorScheme.primary
+ val iconTint = element.iconColor?.let { Color(it) } ?: MaterialTheme.colorScheme.onPrimary
+ val cornerRadius = element.cornerRadius.dp
+ val isTransparent = element.backgroundColor == 0x00000000
+ val buttonPadding = element.padding?.let { parseCssPadding(it) } ?: PaddingValues(0.dp)
+ val textShadow = element.textShadowColor?.let {
+ Shadow(
+ color = Color(it),
+ offset = Offset(element.textShadowOffsetX, element.textShadowOffsetY),
+ blurRadius = element.textShadowRadius
+ )
+ }
+ val textStyle = textShadow?.let { TextStyle(shadow = it) } ?: TextStyle.Default
+
+ HighlightableBox(
+ id = elementId,
+ highlightStyle = highlightStyle,
+ cornerRadius = cornerRadius,
+ navigationLinks = navigationLinks,
+ modifier = Modifier
+ .align(alignment)
+ .offset(x = offsetX, y = offsetY)
+ ) {
+ if (isTransparent) {
+ // Use simple Row for transparent background (no elevation/blur effects)
+ Row(
+ modifier = Modifier
+ .defaultMinSize(minWidth = buttonSize, minHeight = buttonSize)
+ .padding(buttonPadding)
+ .clickable(onClick = callbacks.onFilterClick),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.FilterList,
+ contentDescription = null,
+ modifier = Modifier.size(iconSize),
+ tint = iconTint,
+ )
+ if (element.expanded && callbacks.filterExpanded) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = "Filters", color = iconTint, style = textStyle)
+ }
+ }
+ } else {
+ // FABs are focusable by default - no extra focusable() modifier needed
+ ExtendedFloatingActionButton(
+ text = { Text(text = "Filters", style = textStyle) },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.FilterList,
+ contentDescription = null,
+ modifier = Modifier.size(iconSize),
+ tint = iconTint,
+ )
+ },
+ expanded = element.expanded && callbacks.filterExpanded,
+ onClick = callbacks.onFilterClick,
+ containerColor = bgColor,
+ contentColor = iconTint,
+ shape = RoundedCornerShape(cornerRadius),
+ modifier = Modifier
+ .defaultMinSize(minWidth = buttonSize, minHeight = buttonSize)
+ .padding(buttonPadding),
+ )
+ }
+ }
+ }
+ }
+
+ is FixedElement.AddButton -> {
+ val highlightStyle = element.toHighlightStyle()
+ val navigationLinks = element.toNavigationLinks()
+ val buttonSize = element.size.dp
+ val iconSize = element.iconSize.dp
+ val bgColor = element.backgroundColor?.let { Color(it) } ?: MaterialTheme.colorScheme.secondary
+ val iconTint = element.iconColor?.let { Color(it) } ?: MaterialTheme.colorScheme.onSecondary
+ val cornerRadius = element.cornerRadius.dp
+ val isTransparent = element.backgroundColor == 0x00000000
+ val addGameText = stringResource(R.string.add_game)
+ val buttonPadding = element.padding?.let { parseCssPadding(it) } ?: PaddingValues(0.dp)
+ val textShadow = element.textShadowColor?.let {
+ Shadow(
+ color = Color(it),
+ offset = Offset(element.textShadowOffsetX, element.textShadowOffsetY),
+ blurRadius = element.textShadowRadius
+ )
+ }
+ val textStyle = textShadow?.let { TextStyle(shadow = it) } ?: TextStyle.Default
+
+ HighlightableBox(
+ id = elementId,
+ highlightStyle = highlightStyle,
+ cornerRadius = cornerRadius,
+ navigationLinks = navigationLinks,
+ modifier = Modifier
+ .align(alignment)
+ .offset(x = offsetX, y = offsetY)
+ ) {
+ if (isTransparent) {
+ // Use simple Row for transparent background (no elevation/blur effects)
+ Row(
+ modifier = Modifier
+ .defaultMinSize(minWidth = buttonSize, minHeight = buttonSize)
+ .padding(buttonPadding)
+ .clickable(onClick = callbacks.onAddClick),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ modifier = Modifier.size(iconSize),
+ tint = iconTint,
+ )
+ if (element.expanded) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(text = addGameText, color = iconTint, style = textStyle)
+ }
+ }
+ } else {
+ // FABs are focusable by default - no extra focusable() modifier needed
+ ExtendedFloatingActionButton(
+ text = { Text(text = addGameText, style = textStyle) },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ modifier = Modifier.size(iconSize),
+ tint = iconTint,
+ )
+ },
+ expanded = element.expanded,
+ onClick = callbacks.onAddClick,
+ containerColor = bgColor,
+ contentColor = iconTint,
+ shape = RoundedCornerShape(cornerRadius),
+ modifier = Modifier
+ .defaultMinSize(minWidth = buttonSize, minHeight = buttonSize)
+ .padding(buttonPadding),
+ )
+ }
+ }
+ }
+
+ is FixedElement.Image -> {
+ val width = dimToDp(element.size.width, parentWidth, parentHeight)
+ val height = dimToDp(element.size.height, parentWidth, parentHeight)
+ val pos = ThemeUtils.calculateAbsoluteAnchoredPosition(rawX, rawY, width, height, element.anchor)
+ val shape = ThemeUtils.parseCornerRadius(element.cornerRadius)
+ val contentScale = when (element.scaleType.lowercase()) {
+ "contain", "fit" -> ContentScale.Fit
+ "stretch", "fill" -> ContentScale.FillBounds
+ "none" -> ContentScale.None
+ else -> ContentScale.Crop // "cover" is default
+ }
+ // Resolve asset path
+ val resolvedSrc = resolveAssetPath(element.src, themeRootDir)
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset(x = pos.x, y = pos.y)
+ .size(width, height)
+ .clip(shape)
+ .graphicsLayer(alpha = element.opacity)
+ ) {
+ if (resolvedSrc.isNotEmpty()) {
+ com.skydoves.landscapist.coil.CoilImage(
+ modifier = Modifier.fillMaxSize(),
+ imageModel = { resolvedSrc },
+ imageOptions = com.skydoves.landscapist.ImageOptions(
+ contentScale = contentScale,
+ contentDescription = "Fixed image",
+ ),
+ )
+ } else {
+ // Placeholder when no src is provided
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFF555555))
+ )
+ }
+ }
+ }
+
+ is FixedElement.Video -> {
+ val w = dimToDp(element.size.width, parentWidth, parentHeight)
+ val h = dimToDp(element.size.height, parentWidth, parentHeight)
+ val pos = ThemeUtils.calculateAbsoluteAnchoredPosition(rawX, rawY, w, h, element.anchor)
+ FixedVideoElement(
+ element = element,
+ width = w,
+ height = h,
+ themeRootDir = themeRootDir,
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset(x = pos.x, y = pos.y)
+ )
+ }
+
+ is FixedElement.Rect -> {
+ val w = dimToDp(element.size.width, parentWidth, parentHeight)
+ val h = dimToDp(element.size.height, parentWidth, parentHeight)
+ val pos = ThemeUtils.calculateAbsoluteAnchoredPosition(rawX, rawY, w, h, element.anchor)
+ SharedElementRenderers.RenderRect(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset(x = pos.x, y = pos.y),
+ width = w,
+ height = h,
+ color = Color(element.color),
+ cornerRadius = element.cornerRadius,
+ borderWidth = element.borderWidth,
+ borderColor = Color(element.borderColor),
+ gradientStart = element.gradientStart?.let { Color(it) },
+ gradientEnd = element.gradientEnd?.let { Color(it) },
+ gradientAngle = element.gradientAngle,
+ opacity = element.opacity,
+ )
+ }
+
+ is FixedElement.Text -> {
+ val w = element.size?.let { dimToDp(it.width, parentWidth, parentHeight) } ?: Dp.Unspecified
+ val h = element.size?.let { dimToDp(it.height, parentWidth, parentHeight) } ?: Dp.Unspecified
+ // For text, use a reasonable default size for anchor calculation if unspecified
+ val wCalc = if (w == Dp.Unspecified) 100.dp else w
+ val hCalc = if (h == Dp.Unspecified) 20.dp else h
+ val pos = ThemeUtils.calculateAbsoluteAnchoredPosition(rawX, rawY, wCalc, hCalc, element.anchor)
+ SharedElementRenderers.RenderText(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset(x = pos.x, y = pos.y),
+ width = if (w == Dp.Unspecified) null else w,
+ height = if (h == Dp.Unspecified) null else h,
+ text = element.text,
+ color = Color(element.color),
+ textSize = element.textSize,
+ maxLines = element.maxLines,
+ textAlign = element.textAlign,
+ fontWeight = element.fontWeight,
+ fontStyle = element.fontStyle,
+ overflow = element.overflow,
+ opacity = element.opacity,
+ )
+ }
+
+ is FixedElement.Shadow -> {
+ val w = dimToDp(element.size.width, parentWidth, parentHeight)
+ val h = dimToDp(element.size.height, parentWidth, parentHeight)
+ val pos = ThemeUtils.calculateAbsoluteAnchoredPosition(rawX, rawY, w, h, element.anchor)
+ SharedElementRenderers.RenderShadow(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset(x = pos.x + element.offsetX.dp, y = pos.y + element.offsetY.dp),
+ width = w,
+ height = h,
+ radius = element.radius,
+ color = Color(element.color),
+ offsetX = element.offsetX,
+ offsetY = element.offsetY,
+ cornerRadius = element.cornerRadius,
+ opacity = element.opacity,
+ )
+ }
+
+ is FixedElement.Border -> {
+ val w = dimToDp(element.size.width, parentWidth, parentHeight)
+ val h = dimToDp(element.size.height, parentWidth, parentHeight)
+ val pos = ThemeUtils.calculateAbsoluteAnchoredPosition(rawX, rawY, w, h, element.anchor)
+ SharedElementRenderers.RenderBorder(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset(x = pos.x, y = pos.y),
+ width = w,
+ height = h,
+ strokeWidth = element.strokeWidth,
+ color = Color(element.color),
+ cornerRadius = element.cornerRadius,
+ opacity = element.opacity,
+ )
+ }
+
+ is FixedElement.Backdrop -> {
+ val w = dimToDp(element.size.width, parentWidth, parentHeight)
+ val h = dimToDp(element.size.height, parentWidth, parentHeight)
+ val pos = ThemeUtils.calculateAbsoluteAnchoredPosition(rawX, rawY, w, h, element.anchor)
+ SharedElementRenderers.RenderBackdrop(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .offset(x = pos.x, y = pos.y),
+ width = w,
+ height = h,
+ blurRadius = element.blurRadius,
+ tintColor = element.tintColor?.let { Color(it) },
+ cornerRadius = element.cornerRadius,
+ opacity = element.opacity,
+ )
+ }
+
+ is FixedElement.SystemTime -> {
+ SystemTimeElement(
+ element = element,
+ modifier = Modifier
+ .align(alignment)
+ .offset(x = offsetX, y = offsetY),
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Default fixed element layout when no theme configuration is provided.
+ */
+@Composable
+private fun BoxScope.RenderDefaultFixedElements(
+ state: LibraryState,
+ listState: LazyGridState,
+ themeName: String,
+ callbacks: FixedElementCallbacks,
+ accountButtonContent: @Composable (iconSize: Dp) -> Unit,
+ searchBarContent: @Composable (app.gamenative.ui.screen.library.components.SearchBarStyle) -> Unit,
+) {
+ // Calculate installed count like LibraryListPane does
+ val installedCount = remember(
+ state.appInfoSortType,
+ state.showSteamInLibrary,
+ state.showCustomGamesInLibrary,
+ state.totalAppsInFilter
+ ) {
+ if (state.appInfoSortType.contains(AppFilter.INSTALLED)) {
+ state.totalAppsInFilter
+ } else {
+ val steamCount = if (state.showSteamInLibrary) {
+ DownloadService.getDownloadDirectoryApps().count()
+ } else 0
+ val customGameCount = if (state.showCustomGamesInLibrary) {
+ PrefManager.customGamesCount
+ } else 0
+ steamCount + customGameCount
+ }
+ }
+
+ // Top bar with header and search
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.TopCenter)
+ .padding(top = 8.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column {
+ Text(
+ text = "GameNative",
+ style = MaterialTheme.typography.headlineSmall.copy(
+ fontWeight = FontWeight.Bold,
+ brush = Brush.horizontalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.primary,
+ MaterialTheme.colorScheme.tertiary
+ )
+ )
+ )
+ )
+ Text(
+ text = "Theme: $themeName",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = stringResource(
+ R.string.library_game_count,
+ state.totalAppsInFilter,
+ installedCount
+ ),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ accountButtonContent(40.dp) // Default icon size
+ }
+ // Search bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ ) {
+ searchBarContent(app.gamenative.ui.screen.library.components.SearchBarStyle())
+ }
+ }
+
+ // Bottom buttons
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(bottom = 24.dp, end = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ if (!callbacks.isSearching) {
+ ExtendedFloatingActionButton(
+ text = { Text(text = "Filters") },
+ icon = { Icon(imageVector = Icons.Default.FilterList, contentDescription = null) },
+ expanded = callbacks.filterExpanded,
+ onClick = callbacks.onFilterClick,
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ )
+ }
+
+ FloatingActionButton(
+ onClick = callbacks.onAddClick,
+ containerColor = MaterialTheme.colorScheme.secondary,
+ contentColor = MaterialTheme.colorScheme.onSecondary,
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add custom game",
+ )
+ }
+ }
+}
+
+// Extension functions
+private fun Anchor.toComposeAlignment(): Alignment = when (this) {
+ Anchor.TOP_LEFT -> Alignment.TopStart
+ Anchor.TOP_CENTER -> Alignment.TopCenter
+ Anchor.TOP_RIGHT -> Alignment.TopEnd
+ Anchor.CENTER_LEFT -> Alignment.CenterStart
+ Anchor.CENTER -> Alignment.Center
+ Anchor.CENTER_RIGHT -> Alignment.CenterEnd
+ Anchor.BOTTOM_LEFT -> Alignment.BottomStart
+ Anchor.BOTTOM_CENTER -> Alignment.BottomCenter
+ Anchor.BOTTOM_RIGHT -> Alignment.BottomEnd
+}
+
+/**
+ * Convert CSS-like positioning to Compose offset.
+ * With CSS-like positioning, positive values always mean "inward" from the anchor edge.
+ *
+ * For example, with anchor=topRight and x=16, y=8:
+ * - x=16 means 16px from the right edge (so Compose offsetX = -16)
+ * - y=8 means 8px from the top edge (so Compose offsetY = 8)
+ */
+private fun calculateCssLikeOffset(rawX: Dp, rawY: Dp, anchor: Anchor): Pair {
+ val offsetX = when (anchor) {
+ Anchor.TOP_LEFT, Anchor.CENTER_LEFT, Anchor.BOTTOM_LEFT -> rawX
+ Anchor.TOP_CENTER, Anchor.CENTER, Anchor.BOTTOM_CENTER -> rawX
+ Anchor.TOP_RIGHT, Anchor.CENTER_RIGHT, Anchor.BOTTOM_RIGHT -> -rawX
+ }
+
+ val offsetY = when (anchor) {
+ Anchor.TOP_LEFT, Anchor.TOP_CENTER, Anchor.TOP_RIGHT -> rawY
+ Anchor.CENTER_LEFT, Anchor.CENTER, Anchor.CENTER_RIGHT -> rawY
+ Anchor.BOTTOM_LEFT, Anchor.BOTTOM_CENTER, Anchor.BOTTOM_RIGHT -> -rawY
+ }
+
+ return Pair(offsetX, offsetY)
+}
+
+/**
+ * Parse CSS-style padding string into Compose PaddingValues.
+ * - "8" = 8dp all sides
+ * - "8 16" = 8dp top/bottom, 16dp left/right
+ * - "8 16 8" = 8dp top, 16dp left/right, 8dp bottom
+ * - "8 16 8 16" = top, right, bottom, left
+ */
+private fun parseCssPadding(value: String): PaddingValues {
+ val parts = value.trim().split("\\s+".toRegex()).mapNotNull { it.toFloatOrNull() }
+ return when (parts.size) {
+ 0 -> PaddingValues(0.dp)
+ 1 -> PaddingValues(parts[0].dp)
+ 2 -> PaddingValues(vertical = parts[0].dp, horizontal = parts[1].dp)
+ 3 -> PaddingValues(top = parts[0].dp, start = parts[1].dp, bottom = parts[2].dp, end = parts[1].dp)
+ else -> PaddingValues(top = parts[0].dp, end = parts[1].dp, bottom = parts[2].dp, start = parts[3].dp)
+ }
+}
+
+// dimToDp is imported from ThemeUtils (same package)
+
+/**
+ * Resolves a relative asset path to a full URI.
+ * - If path starts with "http://" or "https://", returns as-is
+ * - If path starts with "file://", returns as-is
+ * - If path starts with "assets/", resolves relative to theme root directory
+ * - Otherwise returns as-is (assumes it's a full path)
+ */
+private fun resolveAssetPath(path: String, themeRootDir: String?): String {
+ if (path.isEmpty()) return path
+ if (path.startsWith("http://") || path.startsWith("https://") || path.startsWith("file://")) {
+ return path
+ }
+ // Resolve relative paths (like "assets/sample.mp4") using theme root
+ if (themeRootDir != null && !path.contains("://")) {
+ val fullPath = java.io.File(themeRootDir, path)
+ if (fullPath.exists()) {
+ return "file://${fullPath.absolutePath}"
+ }
+ }
+ return path
+}
+
+/**
+ * Renders a fixed video element with ExoPlayer.
+ * Shows poster image with play indicator if no video source is provided,
+ * otherwise plays the video with the specified settings.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+private fun FixedVideoElement(
+ element: FixedElement.Video,
+ width: Dp,
+ height: Dp,
+ themeRootDir: String? = null,
+ modifier: Modifier = Modifier,
+) {
+ val context = LocalContext.current
+ val shape = ThemeUtils.parseCornerRadius(element.cornerRadius)
+
+ // Resolve asset paths
+ val resolvedSrc = resolveAssetPath(element.src, themeRootDir)
+ val resolvedPoster = element.poster?.let { resolveAssetPath(it, themeRootDir) }
+
+ // If no video source, show placeholder with poster
+ if (resolvedSrc.isEmpty()) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .size(width, height)
+ .clip(shape)
+ .graphicsLayer(alpha = element.opacity)
+ .background(Color(0xFF303030))
+ ) {
+ if (!resolvedPoster.isNullOrEmpty()) {
+ com.skydoves.landscapist.coil.CoilImage(
+ modifier = Modifier.fillMaxSize(),
+ imageModel = { resolvedPoster },
+ imageOptions = com.skydoves.landscapist.ImageOptions(
+ contentScale = ContentScale.Crop,
+ contentDescription = "Video poster",
+ ),
+ )
+ }
+ Text(
+ text = "▶",
+ color = Color.White,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ return
+ }
+
+ // Create and remember ExoPlayer instance
+ val exoPlayer = remember(resolvedSrc) {
+ ExoPlayer.Builder(context).build().apply {
+ val mediaItem = MediaItem.fromUri(resolvedSrc)
+ setMediaItem(mediaItem)
+ repeatMode = if (element.loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF
+ volume = if (element.muted) 0f else 1f
+ playWhenReady = element.autoplay
+ prepare()
+ }
+ }
+
+ // Clean up player when composable leaves composition
+ DisposableEffect(exoPlayer) {
+ onDispose {
+ exoPlayer.release()
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .size(width, height)
+ .clip(shape)
+ .graphicsLayer(alpha = element.opacity)
+ ) {
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ player = exoPlayer
+ useController = false // Hide playback controls
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+}
+
+/**
+ * Renders a system time element that shows the current device time.
+ * Updates every second to keep the time current.
+ */
+@Composable
+private fun SystemTimeElement(
+ element: FixedElement.SystemTime,
+ modifier: Modifier = Modifier,
+) {
+ // State to hold the current formatted time
+ var currentTime by remember { mutableStateOf("") }
+
+ // Time format pattern based on use24Hour setting
+ val timeFormat = remember(element.use24Hour) {
+ if (element.use24Hour) {
+ SimpleDateFormat("HH:mm", Locale.getDefault())
+ } else {
+ SimpleDateFormat("h:mm a", Locale.getDefault())
+ }
+ }
+
+ // Update time every second
+ LaunchedEffect(Unit) {
+ while (true) {
+ currentTime = timeFormat.format(Date())
+ delay(1000L)
+ }
+ }
+
+ // Determine font weight
+ val fontWeight = when (element.fontWeight.lowercase()) {
+ "bold" -> FontWeight.Bold
+ "medium" -> FontWeight.Medium
+ "semibold" -> FontWeight.SemiBold
+ "light" -> FontWeight.Light
+ "thin" -> FontWeight.Thin
+ "extrabold", "extra-bold" -> FontWeight.ExtraBold
+ "black" -> FontWeight.Black
+ else -> FontWeight.Normal
+ }
+
+ // Text color - default to white if not specified
+ val textColor = element.textColor?.let { Color(it) } ?: Color.White
+
+ Text(
+ text = currentTime,
+ color = textColor,
+ fontSize = element.textSize.sp,
+ fontWeight = fontWeight,
+ modifier = modifier,
+ )
+}
diff --git a/app/src/main/java/app/gamenative/theme/runtime/LayoutEngine.kt b/app/src/main/java/app/gamenative/theme/runtime/LayoutEngine.kt
new file mode 100644
index 000000000..61e4c67bf
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/LayoutEngine.kt
@@ -0,0 +1,394 @@
+package app.gamenative.theme.runtime
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.PagerDefaults
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.lerp
+import app.gamenative.theme.model.*
+import app.gamenative.theme.runtime.layers.RenderLayer
+import kotlin.math.absoluteValue
+
+/**
+ * Simple binding context to resolve literal-or-binding values at render time.
+ * This is a hook; a real BindingEngine can be introduced later.
+ */
+interface BindingContext {
+ fun resolveString(value: StringOrBinding): String?
+ fun resolveFloat(value: FloatOrBinding): Float?
+ fun resolveInt(value: IntOrBinding): Int?
+}
+
+/** A trivial binding context for previews/tests backed by string/number maps. */
+class MapBindingContext(
+ private val strings: Map = emptyMap(),
+ private val floats: Map = emptyMap(),
+ private val ints: Map = emptyMap(),
+) : BindingContext {
+ override fun resolveString(value: StringOrBinding): String? = when (value) {
+ is StringOrBinding.Literal -> value.value
+ is StringOrBinding.Ref -> strings[value.binding.path]
+ }
+ override fun resolveFloat(value: FloatOrBinding): Float? = when (value) {
+ is FloatOrBinding.Literal -> value.value
+ is FloatOrBinding.Ref -> floats[value.binding.path]
+ }
+ override fun resolveInt(value: IntOrBinding): Int? = when (value) {
+ is IntOrBinding.Literal -> value.value
+ is IntOrBinding.Ref -> ints[value.binding.path]
+ }
+}
+
+// Utility functions imported from ThemeUtils (same package)
+
+/** Compute positioned size and offset for a child within a parent box, using ThemeUtils. */
+private fun computePlacement(
+ parentSize: DpSize,
+ pos: DimOffset,
+ size: DimSize?,
+ defaultSize: DpSize,
+ anchor: Anchor,
+): ThemeUtils.Placement = ThemeUtils.calculatePlacement(parentSize, pos, size, defaultSize, anchor)
+
+/** Render a full layout tree. */
+@Composable
+fun ThemeLayout(
+ layout: LayoutNode,
+ cards: Map,
+ binding: BindingContext,
+ modifier: Modifier = Modifier,
+ anchor: Anchor = Anchor.TOP_LEFT,
+ viewportSize: DpSize = DpSize(1080.dp, 640.dp),
+ itemBindingProvider: ((Int) -> BindingContext)? = null,
+) {
+ // Use the actual available space from parent so themes scale to screen size.
+ BoxWithConstraints(modifier = modifier) {
+ val vp = DpSize(maxWidth, maxHeight)
+ when (layout) {
+ is LayoutNode.Canvas -> CanvasLayout(layout, cards, binding, Modifier.fillMaxSize(), anchor, vp)
+ is LayoutNode.Grid -> GridLayout(layout, cards, binding, Modifier.fillMaxSize(), anchor, vp, itemBindingProvider)
+ is LayoutNode.Carousel -> CarouselLayout(layout, cards, binding, Modifier.fillMaxSize(), anchor, vp, itemBindingProvider)
+ }
+ }
+}
+
+@Composable
+private fun CanvasLayout(
+ node: LayoutNode.Canvas,
+ cards: Map,
+ binding: BindingContext,
+ modifier: Modifier,
+ anchor: Anchor,
+ viewportSize: DpSize,
+) {
+ val w = dimToDp(node.size.width, viewportSize.width, viewportSize.height)
+ val h = dimToDp(node.size.height, viewportSize.width, viewportSize.height)
+ val canvasSize = DpSize(w, h)
+ Box(modifier = modifier.size(w, h)) {
+ node.children.forEach { child ->
+ val card = cards[child.cardId] ?: return@forEach
+ val place = computePlacement(canvasSize, child.position, child.size, card.canvas.toDpSize(canvasSize), anchor)
+ Box(
+ modifier = Modifier
+ .offset(x = place.x, y = place.y)
+ .size(place.width, place.height)
+ ) {
+ RenderCard(card, binding, anchor, canvasSize)
+ }
+ }
+ }
+}
+
+@Composable
+private fun GridLayout(
+ node: LayoutNode.Grid,
+ cards: Map,
+ binding: BindingContext,
+ modifier: Modifier,
+ anchor: Anchor,
+ viewportSize: DpSize,
+ itemBindingProvider: ((Int) -> BindingContext)? = null,
+) {
+ val card = cards[node.itemCard] ?: return
+ val cellW = dimToDp(node.cellWidth, viewportSize.width, viewportSize.height)
+ // If cellHeight not specified, use the card's canvas height
+ val cellH = node.cellHeight?.let { dimToDp(it, viewportSize.width, viewportSize.height) }
+ ?: dimToDp(card.canvas.height, viewportSize.width, viewportSize.height)
+ val hSpace = node.hSpacing.dp
+ val vSpace = node.vSpacing.dp
+ // Default to 1 column if not specified
+ val columns = node.columns ?: 1
+ val rows = node.rows ?: 3
+
+ // Vertical alignment for items within cells
+ val verticalAlignment = when (node.verticalAlign) {
+ VerticalAlign.TOP -> Alignment.Top
+ VerticalAlign.CENTER -> Alignment.CenterVertically
+ VerticalAlign.BOTTOM -> Alignment.Bottom
+ }
+
+ Column(modifier = modifier) {
+ repeat(rows) { r ->
+ Row(verticalAlignment = verticalAlignment) {
+ repeat(columns) { c ->
+ val index = r * columns + c
+ val itemBinding = itemBindingProvider?.invoke(index) ?: binding
+ Box(modifier = Modifier.size(cellW, cellH)) {
+ RenderCard(card, itemBinding, anchor, DpSize(cellW, cellH))
+ }
+ if (c != columns - 1) Spacer(Modifier.width(hSpace))
+ }
+ }
+ if (r != rows - 1) Spacer(Modifier.height(vSpace))
+ }
+ }
+}
+
+@Composable
+private fun CarouselLayout(
+ node: LayoutNode.Carousel,
+ cards: Map,
+ binding: BindingContext,
+ modifier: Modifier,
+ anchor: Anchor,
+ viewportSize: DpSize,
+ itemBindingProvider: ((Int) -> BindingContext)? = null,
+) {
+ val card = cards[node.itemCard] ?: return
+ val itemW = dimToDp(node.itemSize.width, viewportSize.width, viewportSize.height)
+ val itemH = dimToDp(node.itemSize.height, viewportSize.width, viewportSize.height)
+ val space = node.itemSpacing.dp
+
+ // Use center-focus pager layout if enabled
+ if (node.centerFocus) {
+ CenterFocusCarouselLayout(
+ node = node,
+ card = card,
+ binding = binding,
+ modifier = modifier,
+ anchor = anchor,
+ itemWidth = itemW,
+ itemHeight = itemH,
+ spacing = space,
+ itemBindingProvider = itemBindingProvider,
+ )
+ } else {
+ // Standard row layout
+ Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
+ val count = node.pageSize ?: 5
+ repeat(count) { i ->
+ val itemBinding = itemBindingProvider?.invoke(i) ?: binding
+ Box(modifier = Modifier.size(itemW, itemH)) {
+ RenderCard(card, itemBinding, anchor, DpSize(itemW, itemH))
+ }
+ if (i != count - 1) Spacer(Modifier.width(space))
+ }
+ }
+ }
+}
+
+/**
+ * Center-focused carousel using HorizontalPager with snap-to-center behavior.
+ * The focused item scales up based on focusedScale.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun CenterFocusCarouselLayout(
+ node: LayoutNode.Carousel,
+ card: Card,
+ binding: BindingContext,
+ modifier: Modifier,
+ anchor: Anchor,
+ itemWidth: Dp,
+ itemHeight: Dp,
+ spacing: Dp,
+ itemBindingProvider: ((Int) -> BindingContext)? = null,
+) {
+ val itemCount = node.pageSize ?: 10
+ val focusedScale = node.focusedScale
+
+ // Account for scale when calculating item size - the focused item will be larger
+ val scaledItemWidth = itemWidth * focusedScale
+ val scaledItemHeight = itemHeight * focusedScale
+
+ val pagerState = rememberPagerState(
+ initialPage = 0,
+ pageCount = { itemCount }
+ )
+
+ // Vertical alignment modifier
+ val verticalAlignmentModifier = when (node.verticalAlign) {
+ VerticalAlign.CENTER -> Modifier.fillMaxSize()
+ VerticalAlign.BOTTOM -> Modifier.fillMaxSize()
+ VerticalAlign.TOP -> Modifier.fillMaxWidth()
+ }
+
+ val verticalArrangement = when (node.verticalAlign) {
+ VerticalAlign.CENTER -> Arrangement.Center
+ VerticalAlign.BOTTOM -> Arrangement.Bottom
+ VerticalAlign.TOP -> Arrangement.Top
+ }
+
+ Column(
+ modifier = modifier.then(verticalAlignmentModifier),
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(scaledItemHeight),
+ contentPadding = PaddingValues(
+ horizontal = (LocalDensity.current.run {
+ // Calculate padding to center the first item
+ // We need to leave space on each side equal to half the screen minus half the item
+ // This centers the current page
+ 0.dp // Will be handled by pageSpacing and item alignment
+ })
+ ),
+ pageSpacing = spacing,
+ flingBehavior = PagerDefaults.flingBehavior(state = pagerState),
+ verticalAlignment = Alignment.CenterVertically,
+ ) { page ->
+ val itemBinding = itemBindingProvider?.invoke(page) ?: binding
+
+ // Calculate scale based on distance from current page
+ val pageOffset = (
+ (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
+ ).absoluteValue.coerceIn(0f, 1f)
+
+ // Scale from focusedScale (at center) to 1.0 (at edges)
+ val scale = lerp(
+ start = focusedScale,
+ stop = 1f,
+ fraction = pageOffset
+ )
+
+ // Also fade non-focused items slightly for depth effect
+ val alpha = lerp(
+ start = 1f,
+ stop = 0.7f,
+ fraction = pageOffset
+ )
+
+ Box(
+ modifier = Modifier
+ .size(itemWidth, itemHeight)
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ this.alpha = alpha
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ RenderCard(card, itemBinding, anchor, DpSize(itemWidth, itemHeight))
+ }
+ }
+ }
+}
+
+@Composable
+private fun RenderCard(
+ card: Card,
+ binding: BindingContext,
+ anchor: Anchor = Anchor.TOP_LEFT,
+ parentSize: DpSize,
+) {
+ Box(modifier = Modifier.size(parentSize.width, parentSize.height)) {
+ card.layers.forEach { layer ->
+ RenderLayer(layer, parentSize, binding, anchor)
+ }
+ }
+}
+
+// -- SelectionEngine integration helpers --
+internal fun gridSelectionConfig(node: LayoutNode.Grid): SelectionEngine.Config = SelectionEngine.Config(
+ rows = node.rows ?: 1,
+ cols = node.columns ?: 1,
+ wrapX = true,
+ wrapY = false,
+ snapToCell = true,
+ pageSize = null,
+ selectionMode = node.selectionMode,
+ centeredSelection = false,
+)
+
+internal fun carouselSelectionConfig(node: LayoutNode.Carousel): SelectionEngine.Config = SelectionEngine.Config(
+ rows = 1,
+ cols = maxOf(1, node.pageSize ?: 1),
+ wrapX = true,
+ wrapY = false,
+ snapToCell = true,
+ pageSize = node.pageSize,
+ selectionMode = node.selectionMode,
+ centeredSelection = (node.selectionMode == SelectionMode.STATIONARY)
+)
+
+@Composable
+private fun DimSize.toDpSize(parent: DpSize): DpSize = DpSize(
+ width = when (val w = this.width) {
+ is Dimension.Px -> with(LocalDensity.current) { w.value.dp }
+ is Dimension.RelW -> parent.width * w.fraction
+ is Dimension.RelH -> parent.height * w.fraction
+ is Dimension.Unspecified -> Dp.Unspecified
+ },
+ height = when (val h = this.height) {
+ is Dimension.Px -> with(LocalDensity.current) { h.value.dp }
+ is Dimension.RelW -> parent.width * h.fraction
+ is Dimension.RelH -> parent.height * h.fraction
+ is Dimension.Unspecified -> Dp.Unspecified
+ }
+)
+
+// --- Preview ---
+
+@Preview(widthDp = 1080, heightDp = 640)
+@Composable
+fun ThemeLayoutPreview_Canvas() {
+ val card = Card(
+ id = "gameCard",
+ canvas = DimSize(Dimension.Px(320f), Dimension.Px(180f)),
+ layers = listOf(
+ Layer.RectLayer(
+ position = DimOffset(Dimension.Px(0f), Dimension.Px(0f)),
+ size = DimSize(Dimension.RelW(1f), Dimension.RelH(1f)),
+ opacity = FloatOrBinding.Literal(1f),
+ color = IntOrBinding.Literal(0xFF2E7D32.toInt()),
+ cornerRadius = "12"
+ ),
+ Layer.TextLayer(
+ position = DimOffset(Dimension.Px(12f), Dimension.Px(12f)),
+ size = null,
+ opacity = null,
+ text = StringOrBinding.Literal("Game Title"),
+ color = IntOrBinding.Literal(0xFFFFFFFF.toInt()),
+ textSize = FloatOrBinding.Literal(20f),
+ maxLines = 1
+ )
+ )
+ )
+ val layout = LayoutNode.Canvas(
+ size = DimSize(Dimension.Px(800f), Dimension.Px(480f)),
+ children = listOf(
+ CanvasChild("gameCard", DimOffset(Dimension.Px(40f), Dimension.Px(40f))),
+ CanvasChild("gameCard", DimOffset(Dimension.Px(360f), Dimension.Px(40f)))
+ )
+ )
+ val binding = remember { MapBindingContext() }
+ ThemeLayout(layout, mapOf(card.id to card), binding, modifier = Modifier.background(Color(0xFF111111)))
+}
diff --git a/app/src/main/java/app/gamenative/theme/runtime/SelectionEngine.kt b/app/src/main/java/app/gamenative/theme/runtime/SelectionEngine.kt
new file mode 100644
index 000000000..335816b38
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/SelectionEngine.kt
@@ -0,0 +1,233 @@
+package app.gamenative.theme.runtime
+
+import androidx.compose.runtime.*
+import app.gamenative.theme.model.Direction
+import app.gamenative.theme.model.SelectionMode
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Selection & index navigation engine. Pure index math + lightweight Compose state helpers.
+ *
+ * This engine manages selection state within scrollable content (grids, carousels) -
+ * NOT to be confused with SpatialFocusManager which handles inter-element focus navigation.
+ *
+ * Supports:
+ * - Stationary vs moving selection
+ * - Rows/cols (grids), wrap, snap-to-cell
+ * - Page size and centered selection for carousels
+ * - Persisting last selection per container key
+ */
+object SelectionEngine {
+
+ // ---- Public Config/State types ----
+
+ data class Config(
+ val rows: Int = 1, // for grids: rows per page (visible)
+ val cols: Int = 1, // for grids: columns per page (visible)
+ val wrapX: Boolean = true, // wrap on horizontal edges
+ val wrapY: Boolean = false, // wrap on vertical edges
+ val snapToCell: Boolean = true, // ensure selection remains inside visible window
+ val pageSize: Int? = null, // for carousels: number of visible items
+ val selectionMode: SelectionMode = SelectionMode.MOVING,
+ val centeredSelection: Boolean = false, // for stationary carousel: keep selection centered when possible
+ ) {
+ init {
+ require(rows >= 1) { "rows must be >= 1" }
+ require(cols >= 1) { "cols must be >= 1" }
+ pageSize?.let { require(it >= 1) { "pageSize must be >= 1" } }
+ }
+ }
+
+ data class State(
+ val totalItems: Int,
+ val config: Config,
+ val containerKey: String? = null,
+ val selectedIndex: Int = 0,
+ val firstVisibleIndex: Int = 0, // start of the visible window
+ ) {
+ init {
+ require(totalItems >= 0)
+ require(selectedIndex >= 0)
+ require(firstVisibleIndex >= 0)
+ }
+
+ val visibleCount: Int
+ get() = config.pageSize ?: (config.rows * config.cols)
+
+ fun visibleRange(): IntRange {
+ if (totalItems == 0) return IntRange.EMPTY
+ val start = firstVisibleIndex.coerceIn(0, max(0, totalItems - 1))
+ val endExclusive = min(totalItems, start + visibleCount)
+ return start until endExclusive
+ }
+
+ fun ensureSelectionVisible(): State {
+ if (totalItems == 0) return this
+ val range = visibleRange()
+ if (selectedIndex in range) return this
+ // Move window to include selection, snapping by page if needed
+ val page = visibleCount
+ val newFirst = when {
+ selectedIndex < range.first -> if (config.selectionMode == SelectionMode.STATIONARY) (selectedIndex / page) * page else selectedIndex
+ else -> {
+ // selected beyond end
+ val start = selectedIndex - (page - 1)
+ if (config.selectionMode == SelectionMode.STATIONARY) (start / page) * page else start
+ }
+ }.coerceIn(0, max(0, totalItems - page))
+ return copy(firstVisibleIndex = newFirst)
+ }
+ }
+
+ // ---- Persist last selection per container ----
+
+ private val lastSelectionByKey = mutableStateMapOf()
+
+ fun readLastSelection(containerKey: String): Int? = lastSelectionByKey[containerKey]
+ fun writeLastSelection(containerKey: String, index: Int) {
+ lastSelectionByKey[containerKey] = index
+ }
+
+ // ---- Navigation operations ----
+
+ fun moveHorizontal(state: State, dir: Direction): State {
+ return when (dir) {
+ Direction.LEFT -> moveLeft(state)
+ Direction.RIGHT -> moveRight(state)
+ else -> state
+ }
+ }
+
+ fun moveVertical(state: State, dir: Direction): State {
+ return when (dir) {
+ Direction.UP -> moveUp(state)
+ Direction.DOWN -> moveDown(state)
+ else -> state
+ }
+ }
+
+ fun page(state: State, forward: Boolean): State {
+ val page = state.visibleCount
+ val delta = if (forward) page else -page
+ return setSelectedIndex(state, state.selectedIndex + delta, axis = Axis.PRIMARY)
+ }
+
+ private enum class Axis { PRIMARY, H, V }
+
+ fun moveLeft(s: State): State = setSelectedIndex(s, s.selectedIndex - 1, Axis.H)
+ fun moveRight(s: State): State = setSelectedIndex(s, s.selectedIndex + 1, Axis.H)
+ fun moveUp(s: State): State = setSelectedIndex(s, s.selectedIndex - s.config.cols, Axis.V)
+ fun moveDown(s: State): State = setSelectedIndex(s, s.selectedIndex + s.config.cols, Axis.V)
+
+ private fun setSelectedIndex(s: State, target: Int, axis: Axis): State {
+ if (s.totalItems == 0) return s
+ val normalized = normalizeTargetIndex(s, target, axis)
+ var newState = s.copy(selectedIndex = normalized)
+
+ // Window/scroll management
+ newState = when (s.config.selectionMode) {
+ SelectionMode.MOVING -> ensureVisibleMoving(newState)
+ SelectionMode.STATIONARY -> ensureVisibleStationary(newState)
+ }
+
+ // Persist
+ s.containerKey?.let { writeLastSelection(it, newState.selectedIndex) }
+ return newState
+ }
+
+ private fun normalizeTargetIndex(s: State, target: Int, axis: Axis): Int {
+ if (s.totalItems == 0) return 0
+ val cols = s.config.cols
+ val rows = s.config.rows
+ var t = target
+ // Horizontal movement:
+ // - If single-row (e.g., carousel) or explicit pageSize set -> move across total range.
+ // - Otherwise treat as grid row-local movement.
+ if (axis == Axis.H) {
+ if (s.config.rows == 1 || s.config.pageSize != null) {
+ // Global wrap/clamp
+ return when {
+ t < 0 -> if (s.config.wrapX) s.totalItems - 1 else 0
+ t >= s.totalItems -> if (s.config.wrapX) 0 else s.totalItems - 1
+ else -> t
+ }
+ } else {
+ val row = s.selectedIndex / cols
+ val rowStart = row * cols
+ val rowEnd = min(rowStart + cols - 1, s.totalItems - 1)
+ if (t < rowStart) {
+ t = if (s.config.wrapX) rowEnd else rowStart
+ } else if (t > rowEnd) {
+ t = if (s.config.wrapX) rowStart else rowEnd
+ }
+ return t.coerceIn(0, s.totalItems - 1)
+ }
+ }
+ // Vertical wrapping per column
+ if (axis == Axis.V) {
+ val col = s.selectedIndex % cols
+ val maxRow = (s.totalItems - 1) / cols
+ var row = s.selectedIndex / cols + if (target > s.selectedIndex) 1 else -1
+ if (row < 0) row = if (s.config.wrapY) maxRow else 0
+ if (row > maxRow) row = if (s.config.wrapY) 0 else maxRow
+ val idx = row * cols + col
+ return idx.coerceIn(0, s.totalItems - 1)
+ }
+ // Primary axis paging simply clamps/wraps globally
+ return when {
+ target < 0 -> if (s.config.wrapX || s.config.wrapY) s.totalItems - 1 else 0
+ target >= s.totalItems -> if (s.config.wrapX || s.config.wrapY) 0 else s.totalItems - 1
+ else -> target
+ }
+ }
+
+ private fun ensureVisibleMoving(s: State): State {
+ if (!s.config.snapToCell) return s
+ val page = s.visibleCount
+ val range = s.visibleRange()
+ if (s.selectedIndex in range) return s
+ val newFirst = when {
+ s.selectedIndex < range.first -> s.selectedIndex
+ else -> s.selectedIndex - (page - 1)
+ }.coerceIn(0, max(0, s.totalItems - page))
+ return s.copy(firstVisibleIndex = newFirst)
+ }
+
+ private fun ensureVisibleStationary(s: State): State {
+ val page = s.visibleCount
+ var anchorOffset = 0
+ if (s.config.centeredSelection && page > 1) {
+ anchorOffset = page / 2
+ }
+ val desiredFirst = (s.selectedIndex - anchorOffset).coerceIn(0, max(0, s.totalItems - page))
+ return s.copy(firstVisibleIndex = desiredFirst)
+ }
+
+ // ---- Compose helpers ----
+
+ @Composable
+ fun rememberSelectionState(
+ containerKey: String,
+ totalItems: Int,
+ config: Config,
+ ): MutableState {
+ val initialIndex = readLastSelection(containerKey) ?: 0
+ val state = remember(containerKey, totalItems, config) {
+ mutableStateOf(State(totalItems, config, containerKey, selectedIndex = initialIndex, firstVisibleIndex = 0))
+ }
+ // Keep totalItems updated while preserving selection in range
+ LaunchedEffect(totalItems) {
+ val cur = state.value
+ val newSel = cur.selectedIndex.coerceIn(0, max(0, totalItems - 1))
+ state.value = cur.copy(totalItems = totalItems, selectedIndex = newSel).ensureSelectionVisible()
+ }
+ return state
+ }
+
+ // Utility to compute index from (row, col) and vice versa
+ fun indexOf(row: Int, col: Int, cols: Int): Int = row * cols + col
+ fun rowOf(index: Int, cols: Int): Int = index / cols
+ fun colOf(index: Int, cols: Int): Int = index % cols
+}
+
diff --git a/app/src/main/java/app/gamenative/theme/runtime/SharedElementRenderers.kt b/app/src/main/java/app/gamenative/theme/runtime/SharedElementRenderers.kt
new file mode 100644
index 000000000..5e5853706
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/SharedElementRenderers.kt
@@ -0,0 +1,347 @@
+package app.gamenative.theme.runtime
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.BlurredEdgeTreatment
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.asComposeRenderEffect
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlin.math.cos
+import kotlin.math.sin
+
+/**
+ * Shared rendering functions for visual elements used in both card layers and fixed elements.
+ * This consolidates duplicate rendering code to ensure consistent behavior.
+ */
+object SharedElementRenderers {
+
+ /**
+ * Render a rectangle with optional fill, gradient, and border.
+ *
+ * @param borderGradient If true, uses the theme's default gradient (tertiary to primary) for the border
+ */
+ @Composable
+ fun RenderRect(
+ modifier: Modifier,
+ width: Dp,
+ height: Dp,
+ color: Color,
+ cornerRadius: String?,
+ borderWidth: Float,
+ borderColor: Color,
+ borderGradient: Boolean = false,
+ gradientStart: Color?,
+ gradientEnd: Color?,
+ gradientAngle: Float,
+ opacity: Float,
+ ) {
+ val shape = parseCornerRadius(cornerRadius)
+ val hasGradient = gradientStart != null && gradientEnd != null
+
+ val gradientBrush = if (hasGradient && gradientStart != null && gradientEnd != null) {
+ createGradientBrush(gradientStart, gradientEnd, gradientAngle, width, height)
+ } else null
+
+ // Create border brush - either gradient or solid color
+ val borderBrush = if (borderGradient) {
+ Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.tertiary,
+ MaterialTheme.colorScheme.primary,
+ )
+ )
+ } else null
+
+ Box(
+ modifier = modifier
+ .size(width, height)
+ .clip(shape)
+ .graphicsLayer(alpha = opacity)
+ .then(
+ if (gradientBrush != null) {
+ Modifier.background(gradientBrush)
+ } else {
+ Modifier.background(color, shape)
+ }
+ )
+ .then(
+ if (borderWidth > 0f) {
+ if (borderBrush != null) {
+ Modifier.border(borderWidth.dp, borderBrush, shape)
+ } else {
+ Modifier.border(borderWidth.dp, borderColor, shape)
+ }
+ } else {
+ Modifier
+ }
+ )
+ )
+ }
+
+ /**
+ * Render static text with styling.
+ *
+ * @param overflow Text overflow behavior: "ellipsis" (add ...), "clip" (hard cut), or "visible" (show all)
+ * @param shadowColor Optional shadow color (null = no shadow)
+ * @param shadowRadius Shadow blur radius
+ * @param shadowOffsetX Shadow horizontal offset
+ * @param shadowOffsetY Shadow vertical offset
+ */
+ @Composable
+ fun RenderText(
+ modifier: Modifier,
+ width: Dp?,
+ height: Dp?,
+ text: String,
+ color: Color,
+ textSize: Float,
+ maxLines: Int?,
+ textAlign: String,
+ fontWeight: String,
+ fontStyle: String,
+ lineHeight: Float? = null,
+ letterSpacing: Float? = null,
+ textDecoration: String? = null,
+ overflow: String = "ellipsis",
+ opacity: Float,
+ shadowColor: Color? = null,
+ shadowRadius: Float = 0f,
+ shadowOffsetX: Float = 0f,
+ shadowOffsetY: Float = 0f,
+ ) {
+ val fontWeightValue = parseFontWeight(fontWeight)
+ val fontStyleValue = parseFontStyle(fontStyle)
+ val textAlignValue = parseTextAlign(textAlign)
+ val textDecorationValue = parseTextDecoration(textDecoration)
+ val textOverflow = parseTextOverflow(overflow)
+
+ val lineHeightSp = lineHeight?.let { if (it > 0f) (it * textSize).sp else TextUnit.Unspecified } ?: TextUnit.Unspecified
+ val letterSpacingSp = letterSpacing?.let { if (it != 0f) it.sp else TextUnit.Unspecified } ?: TextUnit.Unspecified
+
+ // Create shadow if color is specified
+ val shadow = shadowColor?.let {
+ Shadow(
+ color = it,
+ offset = Offset(shadowOffsetX, shadowOffsetY),
+ blurRadius = shadowRadius
+ )
+ }
+
+ val sizeModifier = if (width != null && height != null) {
+ Modifier.size(width, height)
+ } else if (width != null) {
+ Modifier.width(width)
+ } else {
+ Modifier
+ }
+
+ Box(
+ modifier = modifier
+ .then(sizeModifier)
+ .graphicsLayer(alpha = opacity)
+ ) {
+ Text(
+ text = text,
+ color = color,
+ fontSize = textSize.sp,
+ fontWeight = fontWeightValue,
+ fontStyle = fontStyleValue,
+ textAlign = textAlignValue,
+ maxLines = maxLines ?: Int.MAX_VALUE,
+ overflow = textOverflow,
+ lineHeight = lineHeightSp,
+ letterSpacing = letterSpacingSp,
+ textDecoration = textDecorationValue,
+ style = shadow?.let { TextStyle(shadow = it) } ?: TextStyle.Default,
+ modifier = if (width != null) Modifier.fillMaxWidth() else Modifier,
+ )
+ }
+ }
+
+ /**
+ * Parse text overflow mode from string.
+ */
+ fun parseTextOverflow(overflow: String?): TextOverflow = when (overflow?.lowercase()) {
+ "clip" -> TextOverflow.Clip
+ "visible" -> TextOverflow.Visible
+ else -> TextOverflow.Ellipsis // Default to ellipsis
+ }
+
+ /**
+ * Render a shadow effect.
+ */
+ @Composable
+ fun RenderShadow(
+ modifier: Modifier,
+ width: Dp,
+ height: Dp,
+ radius: Float,
+ color: Color,
+ offsetX: Float,
+ offsetY: Float,
+ cornerRadius: String?,
+ opacity: Float,
+ ) {
+ val shape = cornerRadius?.let { parseCornerRadius(it) } ?: RectangleShape
+ val elevationDp = if (radius > 0f) (radius / 2).dp else 0.dp
+
+ Box(
+ modifier = modifier
+ .size(width, height)
+ .shadow(
+ elevation = elevationDp,
+ shape = shape,
+ ambientColor = color,
+ spotColor = color
+ )
+ .graphicsLayer(alpha = opacity)
+ .background(Color.Transparent)
+ )
+ }
+
+ /**
+ * Render a border/stroke around a rectangular area.
+ */
+ @Composable
+ fun RenderBorder(
+ modifier: Modifier,
+ width: Dp,
+ height: Dp,
+ strokeWidth: Float,
+ color: Color,
+ cornerRadius: String?,
+ opacity: Float,
+ ) {
+ val shape = parseCornerRadius(cornerRadius)
+
+ Box(
+ modifier = modifier
+ .size(width, height)
+ .clip(shape)
+ .graphicsLayer(alpha = opacity)
+ .border(width = strokeWidth.dp, color = color, shape = shape)
+ )
+ }
+
+ /**
+ * Render a backdrop blur effect.
+ * Uses native blur on API 31+, otherwise applies tint only.
+ */
+ @Composable
+ fun RenderBackdrop(
+ modifier: Modifier,
+ width: Dp,
+ height: Dp,
+ blurRadius: Float,
+ tintColor: Color?,
+ cornerRadius: String?,
+ opacity: Float,
+ ) {
+ val shape = parseCornerRadius(cornerRadius)
+ val tint = tintColor ?: Color.Transparent
+
+ Box(
+ modifier = modifier
+ .size(width, height)
+ .clip(shape)
+ .graphicsLayer(alpha = opacity)
+ // Use native blur API when available (API 31+)
+ .then(
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S && blurRadius > 0f) {
+ Modifier.graphicsLayer {
+ renderEffect = android.graphics.RenderEffect.createBlurEffect(
+ blurRadius,
+ blurRadius,
+ android.graphics.Shader.TileMode.CLAMP
+ ).asComposeRenderEffect()
+ }
+ } else if (blurRadius > 0f) {
+ // Fallback to Compose blur for older devices
+ Modifier.blur(radius = blurRadius.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
+ } else {
+ Modifier
+ }
+ )
+ .background(tint, shape)
+ )
+ }
+
+ // --- Helper functions ---
+
+ fun parseFontWeight(value: String): FontWeight = when (value.lowercase()) {
+ "bold" -> FontWeight.Bold
+ "semibold" -> FontWeight.SemiBold
+ "medium" -> FontWeight.Medium
+ "light" -> FontWeight.Light
+ "thin" -> FontWeight.Thin
+ "extrabold", "black" -> FontWeight.ExtraBold
+ else -> FontWeight.Normal
+ }
+
+ fun parseFontStyle(value: String): FontStyle = when (value.lowercase()) {
+ "italic" -> FontStyle.Italic
+ else -> FontStyle.Normal
+ }
+
+ fun parseTextAlign(value: String): TextAlign = when (value.lowercase()) {
+ "center" -> TextAlign.Center
+ "right", "end" -> TextAlign.End
+ else -> TextAlign.Start
+ }
+
+ fun parseTextDecoration(value: String?): TextDecoration = when (value?.lowercase()) {
+ "underline" -> TextDecoration.Underline
+ "linethrough", "line-through", "strikethrough" -> TextDecoration.LineThrough
+ else -> TextDecoration.None
+ }
+
+ private fun createGradientBrush(
+ startColor: Color,
+ endColor: Color,
+ angleDegrees: Float,
+ width: Dp,
+ height: Dp,
+ ): Brush {
+ val angleRad = Math.toRadians(angleDegrees.toDouble())
+ val cos = cos(angleRad).toFloat()
+ val sin = sin(angleRad).toFloat()
+ // Normalize to 0-1 range for Offset
+ val startX = 0.5f - cos * 0.5f
+ val startY = 0.5f + sin * 0.5f
+ val endX = 0.5f + cos * 0.5f
+ val endY = 0.5f - sin * 0.5f
+ return Brush.linearGradient(
+ colors = listOf(startColor, endColor),
+ start = Offset(startX * width.value, startY * height.value),
+ end = Offset(endX * width.value, endY * height.value),
+ )
+ }
+}
diff --git a/app/src/main/java/app/gamenative/theme/runtime/SpatialFocusManager.kt b/app/src/main/java/app/gamenative/theme/runtime/SpatialFocusManager.kt
new file mode 100644
index 000000000..cc4df1c79
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/SpatialFocusManager.kt
@@ -0,0 +1,361 @@
+package app.gamenative.theme.runtime
+
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.geometry.Rect
+import kotlin.math.abs
+import timber.log.Timber
+
+/**
+ * Manages spatial focus navigation for themed UI elements.
+ *
+ * This manager tracks focusable elements and their screen positions,
+ * allowing navigation based on actual visual placement rather than
+ * composition tree order.
+ */
+class SpatialFocusManager {
+
+ /**
+ * Explicit navigation overrides for an element.
+ * When set, these take priority over spatial navigation.
+ */
+ data class NavigationLinks(
+ val up: String? = null,
+ val down: String? = null,
+ val left: String? = null,
+ val right: String? = null,
+ )
+
+ /**
+ * Callback interface for dynamic focus handling.
+ * When an element supports dynamic focus (e.g., a grid with many items),
+ * this callback allows the element to determine the best focus target
+ * based on the navigation source's position.
+ *
+ * @param sourceBounds The bounds of the element requesting navigation to this target
+ * @return true if focus was handled, false to fall back to default behavior
+ */
+ fun interface DynamicFocusHandler {
+ fun handleFocus(sourceBounds: Rect): Boolean
+ }
+
+ data class FocusableElement(
+ val id: String,
+ val bounds: Rect,
+ val focusRequester: FocusRequester,
+ val navigationLinks: NavigationLinks = NavigationLinks(),
+ /** Optional handler for dynamic focus targets (like grids/lists with many items) */
+ val dynamicFocusHandler: DynamicFocusHandler? = null,
+ )
+
+ enum class Direction {
+ UP, DOWN, LEFT, RIGHT
+ }
+
+ private val elements = mutableStateMapOf()
+ private var currentFocusedId: String? = null
+
+ /**
+ * Register a focusable element with its screen bounds.
+ * Should be called from onGloballyPositioned modifier.
+ *
+ * @param id Unique identifier for the element
+ * @param bounds Screen bounds of the element
+ * @param focusRequester FocusRequester to request focus on this element
+ * @param navigationLinks Optional explicit navigation overrides
+ * @param dynamicFocusHandler Optional callback for dynamic focus handling (e.g., grids)
+ */
+ fun register(
+ id: String,
+ bounds: Rect,
+ focusRequester: FocusRequester,
+ navigationLinks: NavigationLinks = NavigationLinks(),
+ dynamicFocusHandler: DynamicFocusHandler? = null,
+ ) {
+ elements[id] = FocusableElement(id, bounds, focusRequester, navigationLinks, dynamicFocusHandler)
+ Timber.tag(TAG).d("Registered '$id' at bounds: left=${bounds.left.toInt()}, top=${bounds.top.toInt()}, right=${bounds.right.toInt()}, bottom=${bounds.bottom.toInt()}, links=${navigationLinks}, hasDynamicHandler=${dynamicFocusHandler != null}")
+ }
+
+ /**
+ * Unregister a focusable element (e.g., when it's removed from composition).
+ */
+ fun unregister(id: String) {
+ elements.remove(id)
+ if (currentFocusedId == id) {
+ currentFocusedId = null
+ }
+ }
+
+ /**
+ * Mark an element as currently focused.
+ */
+ fun setFocused(id: String) {
+ currentFocusedId = id
+ }
+
+ /**
+ * Get the currently focused element ID.
+ */
+ fun getCurrentFocusedId(): String? = currentFocusedId
+
+ /**
+ * Navigate directly to a specific element by ID.
+ * Returns true if navigation was successful.
+ *
+ * @param targetId The ID of the element to navigate to
+ * @param sourceBounds Optional bounds of the source element (used for dynamic focus handlers)
+ */
+ fun navigateTo(targetId: String, sourceBounds: Rect? = null): Boolean {
+ val target = elements[targetId] ?: run {
+ Timber.tag(TAG).d("Navigate to '$targetId' - element not found! Registered: ${elements.keys}")
+ return false
+ }
+
+ // If target has a dynamic focus handler and we have source bounds, use it
+ if (target.dynamicFocusHandler != null && sourceBounds != null) {
+ Timber.tag(TAG).d("Navigate to '$targetId' using dynamic focus handler (source: $sourceBounds)")
+ if (target.dynamicFocusHandler.handleFocus(sourceBounds)) {
+ currentFocusedId = targetId
+ return true
+ }
+ Timber.tag(TAG).d("Dynamic handler returned false, falling back to default")
+ }
+
+ Timber.tag(TAG).d("Navigate directly to '$targetId'")
+ return requestFocusOn(target)
+ }
+
+ /**
+ * Navigate from the specified element in the given direction.
+ * First checks for explicit navigation links, then falls back to spatial navigation.
+ * Returns true if navigation was successful.
+ */
+ fun navigateInDirection(fromId: String, direction: Direction): Boolean {
+ val fromElement = elements[fromId] ?: run {
+ Timber.tag(TAG).d("Navigate from '$fromId' $direction - element not found! Registered: ${elements.keys}")
+ return false
+ }
+
+ Timber.tag(TAG).d("Navigate from '$fromId' $direction (bounds: ${fromElement.bounds})")
+
+ // Check for explicit navigation link first
+ val explicitTargetId = getExplicitTarget(fromElement, direction)
+ if (explicitTargetId != null) {
+ val explicitTarget = elements[explicitTargetId]
+ if (explicitTarget != null) {
+ Timber.tag(TAG).d("Using explicit navigation link to '$explicitTargetId'")
+ // Use navigateTo to leverage dynamic focus handler if available
+ return navigateTo(explicitTargetId, fromElement.bounds)
+ } else {
+ Timber.tag(TAG).w("Explicit navigation target '$explicitTargetId' not found, falling back to spatial")
+ }
+ }
+
+ // Fall back to spatial navigation
+ val target = findNearestInDirection(fromElement.bounds, direction, fromId)
+
+ return if (target != null) {
+ Timber.tag(TAG).d("Found target '${target.id}' for $direction navigation (spatial)")
+ // Use navigateTo to leverage dynamic focus handler if available
+ navigateTo(target.id, fromElement.bounds)
+ } else {
+ Timber.tag(TAG).d("No target found for $direction navigation from '$fromId'")
+ false
+ }
+ }
+
+ /**
+ * Get the explicit navigation target ID for the given direction, if set.
+ */
+ private fun getExplicitTarget(element: FocusableElement, direction: Direction): String? {
+ return when (direction) {
+ Direction.UP -> element.navigationLinks.up
+ Direction.DOWN -> element.navigationLinks.down
+ Direction.LEFT -> element.navigationLinks.left
+ Direction.RIGHT -> element.navigationLinks.right
+ }
+ }
+
+ /**
+ * Request focus on the target element.
+ */
+ private fun requestFocusOn(target: FocusableElement): Boolean {
+ return try {
+ target.focusRequester.requestFocus()
+ currentFocusedId = target.id
+ true
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Failed to request focus on '${target.id}'")
+ false
+ }
+ }
+
+ /**
+ * Find the nearest element in the specified direction from the source bounds.
+ * Uses a two-pass algorithm:
+ * 1. First, prefer elements that are "aligned" (overlapping on the secondary axis)
+ * 2. If no aligned elements, fall back to all candidates
+ */
+ private fun findNearestInDirection(
+ from: Rect,
+ direction: Direction,
+ excludeId: String
+ ): FocusableElement? {
+ Timber.tag(TAG).d("Finding nearest $direction from bounds: $from")
+ Timber.tag(TAG).d("All registered elements: ${elements.keys}")
+
+ val allCandidates = elements.values.filter { element ->
+ val isCandidate = element.id != excludeId && isInDirection(from, element.bounds, direction)
+ if (element.id != excludeId) {
+ Timber.tag(TAG).d("Checking '${element.id}' - isInDirection($direction): $isCandidate, bounds: ${element.bounds}")
+ }
+ isCandidate
+ }
+
+ if (allCandidates.isEmpty()) {
+ Timber.tag(TAG).d("No candidates found for $direction")
+ return null
+ }
+
+ // First pass: find elements that are "aligned" (overlapping on secondary axis)
+ val alignedCandidates = allCandidates.filter { element ->
+ isAligned(from, element.bounds, direction)
+ }
+
+ // Use aligned candidates if any exist, otherwise fall back to all candidates
+ val candidates = if (alignedCandidates.isNotEmpty()) {
+ Timber.tag(TAG).d("Aligned candidates for $direction: ${alignedCandidates.map { it.id }}")
+ alignedCandidates
+ } else {
+ Timber.tag(TAG).d("No aligned candidates, using all: ${allCandidates.map { it.id }}")
+ allCandidates
+ }
+
+ // Score each candidate - lower score is better
+ val fromCenterX = from.left + from.width / 2
+ val fromCenterY = from.top + from.height / 2
+ Timber.tag(TAG).d("Source center: ($fromCenterX, $fromCenterY)")
+
+ val scored = candidates.map { element ->
+ val candidateCenterX = element.bounds.left + element.bounds.width / 2
+ val candidateCenterY = element.bounds.top + element.bounds.height / 2
+ val score = calculateScore(from, element.bounds, direction)
+ val vertDist = abs(candidateCenterY - fromCenterY)
+ val horizDist = abs(candidateCenterX - fromCenterX)
+ Timber.tag(TAG).d("Score for '${element.id}': $score (vertDist=$vertDist, horizDist=$horizDist, center=($candidateCenterX, $candidateCenterY))")
+ element to score
+ }
+
+ return scored.minByOrNull { it.second }?.first
+ }
+
+ /**
+ * Check if two bounds are "aligned" for the given direction.
+ *
+ * For LEFT/RIGHT: elements are aligned if their vertical centers are close (same row)
+ * For UP/DOWN: no alignment filtering - any element above/below is a candidate
+ *
+ * This ensures:
+ * - LEFT/RIGHT stays on the same row (filter ↔ add, search ↔ profile)
+ * - UP/DOWN can reach elements even if they're not horizontally aligned
+ */
+ private fun isAligned(from: Rect, candidate: Rect, direction: Direction): Boolean {
+ val fromCenterY = from.top + from.height / 2
+ val candidateCenterY = candidate.top + candidate.height / 2
+
+ return when (direction) {
+ Direction.UP, Direction.DOWN -> {
+ // No alignment filtering for UP/DOWN - any element above/below is a candidate
+ // The scoring will pick the closest one
+ true
+ }
+ Direction.LEFT, Direction.RIGHT -> {
+ // Check if vertical centers are within a fixed threshold (same row)
+ val verticalOffset = abs(candidateCenterY - fromCenterY)
+ verticalOffset <= SAME_ROW_THRESHOLD
+ }
+ }
+ }
+
+ /**
+ * Check if the candidate bounds are in the specified direction from the source.
+ * Uses center points for more intuitive navigation, especially when elements overlap.
+ *
+ * For UP/DOWN: Also excludes elements that are on the "same row" (vertical centers within threshold)
+ * This prevents navigation between search ↔ profile via UP/DOWN
+ */
+ private fun isInDirection(from: Rect, candidate: Rect, direction: Direction): Boolean {
+ val fromCenterX = from.left + from.width / 2
+ val fromCenterY = from.top + from.height / 2
+ val candidateCenterX = candidate.left + candidate.width / 2
+ val candidateCenterY = candidate.top + candidate.height / 2
+
+ return when (direction) {
+ Direction.DOWN -> {
+ // Candidate's center must be below AND not on the same row
+ val verticalDiff = candidateCenterY - fromCenterY
+ verticalDiff > SAME_ROW_THRESHOLD
+ }
+ Direction.UP -> {
+ // Candidate's center must be above AND not on the same row
+ val verticalDiff = fromCenterY - candidateCenterY
+ verticalDiff > SAME_ROW_THRESHOLD
+ }
+ // Candidate's center is left of source's center
+ Direction.LEFT -> candidateCenterX < fromCenterX
+ // Candidate's center is right of source's center
+ Direction.RIGHT -> candidateCenterX > fromCenterX
+ }
+ }
+
+ /**
+ * Calculate a score for the candidate element.
+ * Lower scores indicate better navigation targets.
+ *
+ * Uses center-to-center distance for more intuitive scoring:
+ * - Primary axis distance (vertical for UP/DOWN, horizontal for LEFT/RIGHT)
+ * - Secondary axis offset as a tiebreaker
+ */
+ private fun calculateScore(from: Rect, candidate: Rect, direction: Direction): Float {
+ val fromCenterX = from.left + from.width / 2
+ val fromCenterY = from.top + from.height / 2
+ val candidateCenterX = candidate.left + candidate.width / 2
+ val candidateCenterY = candidate.top + candidate.height / 2
+
+ return when (direction) {
+ Direction.DOWN, Direction.UP -> {
+ // Primary: vertical center-to-center distance
+ val verticalDistance = abs(candidateCenterY - fromCenterY)
+ val horizontalOffset = abs(candidateCenterX - fromCenterX)
+ verticalDistance + (horizontalOffset * SECONDARY_AXIS_WEIGHT)
+ }
+ Direction.LEFT, Direction.RIGHT -> {
+ // Primary: horizontal center-to-center distance
+ val horizontalDistance = abs(candidateCenterX - fromCenterX)
+ val verticalOffset = abs(candidateCenterY - fromCenterY)
+ horizontalDistance + (verticalOffset * SECONDARY_AXIS_WEIGHT)
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "SpatialFocus"
+
+ // Multiplier for secondary axis offset when scoring
+ // Higher values prefer elements more directly in the navigation direction
+ // 0.2 means: 100px horizontal offset adds 20 to the score
+ private const val SECONDARY_AXIS_WEIGHT = 0.2f
+
+ // Fixed threshold in pixels for considering elements on the "same row"
+ // Elements with vertical centers within this distance are on the same row
+ // 200px works well for typical button heights (50-150px)
+ private const val SAME_ROW_THRESHOLD = 200f
+ }
+}
+
+/**
+ * CompositionLocal for providing the SpatialFocusManager to themed components.
+ */
+val LocalSpatialFocusManager = compositionLocalOf { null }
+
diff --git a/app/src/main/java/app/gamenative/theme/runtime/ThemeUtils.kt b/app/src/main/java/app/gamenative/theme/runtime/ThemeUtils.kt
new file mode 100644
index 000000000..462348c47
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/ThemeUtils.kt
@@ -0,0 +1,221 @@
+package app.gamenative.theme.runtime
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import app.gamenative.theme.model.Anchor
+import app.gamenative.theme.model.Dimension
+import app.gamenative.theme.model.DimOffset
+import app.gamenative.theme.model.DimSize
+
+/**
+ * Shared utility functions for the theme engine.
+ * Consolidates common operations used across LayerRenderers, ThemedGameGrid,
+ * FixedElementRenderer, and LayoutEngine.
+ */
+object ThemeUtils {
+
+ /**
+ * Convert a [Dimension] to [Dp], resolving relative dimensions using parent size.
+ *
+ * @param d The dimension to convert
+ * @param parentW Parent width for RelW calculations
+ * @param parentH Parent height for RelH calculations
+ */
+ fun dimToDp(d: Dimension, parentW: Dp, parentH: Dp): Dp = when (d) {
+ is Dimension.Px -> d.value.dp
+ is Dimension.RelW -> parentW * d.fraction
+ is Dimension.RelH -> parentH * d.fraction
+ is Dimension.Unspecified -> Dp.Unspecified
+ }
+
+ /**
+ * Parse CSS-like corner radius string into a [RoundedCornerShape].
+ *
+ * Supports 1-4 values following CSS shorthand convention:
+ * - "8" = all corners 8dp
+ * - "8 4" = top-left/bottom-right 8dp, top-right/bottom-left 4dp
+ * - "8 4 2" = top-left 8dp, top-right/bottom-left 4dp, bottom-right 2dp
+ * - "8 4 2 1" = top-left 8dp, top-right 4dp, bottom-right 2dp, bottom-left 1dp
+ *
+ * @param value CSS-like corner radius string, or null for no rounding
+ * @return Appropriate [RoundedCornerShape]
+ */
+ fun parseCornerRadius(value: String?): RoundedCornerShape {
+ if (value.isNullOrBlank()) return RoundedCornerShape(0.dp)
+ val parts = value.trim().split("\\s+".toRegex()).mapNotNull { it.toFloatOrNull() }
+ return when (parts.size) {
+ 0 -> RoundedCornerShape(0.dp)
+ 1 -> RoundedCornerShape(parts[0].dp)
+ 2 -> RoundedCornerShape(
+ topStart = parts[0].dp,
+ topEnd = parts[1].dp,
+ bottomEnd = parts[0].dp,
+ bottomStart = parts[1].dp
+ )
+ 3 -> RoundedCornerShape(
+ topStart = parts[0].dp,
+ topEnd = parts[1].dp,
+ bottomEnd = parts[2].dp,
+ bottomStart = parts[1].dp
+ )
+ else -> RoundedCornerShape(
+ topStart = parts[0].dp,
+ topEnd = parts[1].dp,
+ bottomEnd = parts[2].dp,
+ bottomStart = parts[3].dp
+ )
+ }
+ }
+
+ /**
+ * Result of calculating element placement within a parent container.
+ */
+ data class Placement(
+ /** Computed X offset from parent's top-left */
+ val x: Dp,
+ /** Computed Y offset from parent's top-left */
+ val y: Dp,
+ /** Computed width */
+ val width: Dp,
+ /** Computed height */
+ val height: Dp
+ )
+
+ /**
+ * Calculate element placement within a parent container, accounting for anchor point.
+ *
+ * The anchor determines which point of the element the position refers to:
+ * - TOP_LEFT: position is from top-left (default CSS behavior)
+ * - TOP_RIGHT: position.x is distance from right edge inward
+ * - BOTTOM_LEFT: position.y is distance from bottom edge upward
+ * - CENTER: position is offset from center
+ * - etc.
+ *
+ * @param parentSize Size of the parent container
+ * @param position Raw position from theme definition
+ * @param size Optional explicit size; if null, uses defaultSize
+ * @param defaultSize Fallback size when size is null
+ * @param anchor Anchor point for positioning
+ * @return Calculated [Placement] with absolute coordinates
+ */
+ fun calculatePlacement(
+ parentSize: DpSize,
+ position: DimOffset,
+ size: DimSize?,
+ defaultSize: DpSize,
+ anchor: Anchor
+ ): Placement {
+ // Resolve dimensions - keep Dp.Unspecified for unspecified dimensions
+ val resolvedW = size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val resolvedH = size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+
+ // For anchor calculations, use defaultSize when dimension is unspecified
+ val wForCalc = if (resolvedW == null || resolvedW == Dp.Unspecified) defaultSize.width else resolvedW
+ val hForCalc = if (resolvedH == null || resolvedH == Dp.Unspecified) defaultSize.height else resolvedH
+
+ // Final dimensions: use resolved value if specified, otherwise null markers via Dp.Unspecified
+ val w = resolvedW ?: defaultSize.width
+ val h = resolvedH ?: defaultSize.height
+
+ val rawX = dimToDp(position.x, parentSize.width, parentSize.height)
+ val rawY = dimToDp(position.y, parentSize.width, parentSize.height)
+
+ val x = when (anchor) {
+ Anchor.TOP_LEFT, Anchor.CENTER_LEFT, Anchor.BOTTOM_LEFT -> rawX
+ Anchor.TOP_CENTER, Anchor.CENTER, Anchor.BOTTOM_CENTER -> (parentSize.width - wForCalc) / 2 + rawX
+ Anchor.TOP_RIGHT, Anchor.CENTER_RIGHT, Anchor.BOTTOM_RIGHT -> parentSize.width - rawX - wForCalc
+ }
+
+ val y = when (anchor) {
+ Anchor.TOP_LEFT, Anchor.TOP_CENTER, Anchor.TOP_RIGHT -> rawY
+ Anchor.CENTER_LEFT, Anchor.CENTER, Anchor.CENTER_RIGHT -> (parentSize.height - hForCalc) / 2 + rawY
+ Anchor.BOTTOM_LEFT, Anchor.BOTTOM_CENTER, Anchor.BOTTOM_RIGHT -> parentSize.height - rawY - hForCalc
+ }
+
+ return Placement(x, y, w, h)
+ }
+
+ /**
+ * Simplified placement calculation when you already have resolved dimensions.
+ *
+ * @param rawX Raw X position in Dp
+ * @param rawY Raw Y position in Dp
+ * @param elementWidth Element width in Dp
+ * @param elementHeight Element height in Dp
+ * @param parentWidth Parent width in Dp
+ * @param parentHeight Parent height in Dp
+ * @param anchor Anchor point for positioning
+ * @return Calculated [Placement] with absolute coordinates
+ */
+ fun calculateAnchoredPosition(
+ rawX: Dp,
+ rawY: Dp,
+ elementWidth: Dp,
+ elementHeight: Dp,
+ parentWidth: Dp,
+ parentHeight: Dp,
+ anchor: Anchor
+ ): Placement {
+ val x = when (anchor) {
+ Anchor.TOP_LEFT, Anchor.CENTER_LEFT, Anchor.BOTTOM_LEFT -> rawX
+ Anchor.TOP_CENTER, Anchor.CENTER, Anchor.BOTTOM_CENTER -> (parentWidth - elementWidth) / 2 + rawX
+ Anchor.TOP_RIGHT, Anchor.CENTER_RIGHT, Anchor.BOTTOM_RIGHT -> parentWidth - elementWidth - rawX
+ }
+
+ val y = when (anchor) {
+ Anchor.TOP_LEFT, Anchor.TOP_CENTER, Anchor.TOP_RIGHT -> rawY
+ Anchor.CENTER_LEFT, Anchor.CENTER, Anchor.CENTER_RIGHT -> (parentHeight - elementHeight) / 2 + rawY
+ Anchor.BOTTOM_LEFT, Anchor.BOTTOM_CENTER, Anchor.BOTTOM_RIGHT -> parentHeight - elementHeight - rawY
+ }
+
+ return Placement(x, y, elementWidth, elementHeight)
+ }
+
+ /**
+ * Calculate element position where x,y represents the ABSOLUTE position of the anchor point.
+ *
+ * Unlike [calculateAnchoredPosition] which treats x,y as offsets from edges (CSS-like),
+ * this function treats x,y as the exact coordinates where the anchor point should be placed.
+ *
+ * For example:
+ * - topLeft with x=80, y=100: element's top-left corner is at (80, 100)
+ * - topRight with x=280, y=100: element's top-right corner is at (280, 100), so left edge = 280 - width
+ * - center with x=200, y=150: element's center is at (200, 150)
+ *
+ * @param anchorX Absolute X coordinate where the anchor point should be
+ * @param anchorY Absolute Y coordinate where the anchor point should be
+ * @param elementWidth Element width
+ * @param elementHeight Element height
+ * @param anchor Which point of the element the coordinates refer to
+ * @return Calculated position for the element's top-left corner
+ */
+ fun calculateAbsoluteAnchoredPosition(
+ anchorX: Dp,
+ anchorY: Dp,
+ elementWidth: Dp,
+ elementHeight: Dp,
+ anchor: Anchor
+ ): Placement {
+ val x = when (anchor) {
+ Anchor.TOP_LEFT, Anchor.CENTER_LEFT, Anchor.BOTTOM_LEFT -> anchorX
+ Anchor.TOP_CENTER, Anchor.CENTER, Anchor.BOTTOM_CENTER -> anchorX - elementWidth / 2
+ Anchor.TOP_RIGHT, Anchor.CENTER_RIGHT, Anchor.BOTTOM_RIGHT -> anchorX - elementWidth
+ }
+
+ val y = when (anchor) {
+ Anchor.TOP_LEFT, Anchor.TOP_CENTER, Anchor.TOP_RIGHT -> anchorY
+ Anchor.CENTER_LEFT, Anchor.CENTER, Anchor.CENTER_RIGHT -> anchorY - elementHeight / 2
+ Anchor.BOTTOM_LEFT, Anchor.BOTTOM_CENTER, Anchor.BOTTOM_RIGHT -> anchorY - elementHeight
+ }
+
+ return Placement(x, y, elementWidth, elementHeight)
+ }
+
+}
+
+// Convenience top-level function aliases for cleaner imports
+fun dimToDp(d: Dimension, parentW: Dp, parentH: Dp): Dp = ThemeUtils.dimToDp(d, parentW, parentH)
+fun parseCornerRadius(value: String?): RoundedCornerShape = ThemeUtils.parseCornerRadius(value)
+
diff --git a/app/src/main/java/app/gamenative/theme/runtime/ThemedGameGrid.kt b/app/src/main/java/app/gamenative/theme/runtime/ThemedGameGrid.kt
new file mode 100644
index 000000000..c3f90fc6c
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/ThemedGameGrid.kt
@@ -0,0 +1,1628 @@
+package app.gamenative.theme.runtime
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.BorderStroke
+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.*
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.itemsIndexed
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.VerticalPager
+import androidx.compose.foundation.pager.PagerDefaults
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import app.gamenative.theme.model.Card as ThemeCard
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.foundation.focusGroup
+import androidx.compose.foundation.focusable
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import kotlinx.coroutines.launch
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.zIndex
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.util.lerp
+import app.gamenative.data.LibraryItem
+import app.gamenative.theme.io.ThemeStringResolver
+import app.gamenative.theme.model.*
+import timber.log.Timber
+import com.skydoves.landscapist.ImageOptions
+import com.skydoves.landscapist.coil.CoilImage
+import kotlin.math.absoluteValue
+
+/**
+ * A themed game grid that renders library items using the theme engine's cards.
+ * Unlike the basic ThemeLayout, this component:
+ * - Supports scrolling via LazyVerticalGrid
+ * - Handles click events for navigation
+ * - Uses all items from the data source
+ * - Supports focus for controller navigation
+ */
+@Composable
+fun ThemedGameGrid(
+ items: List,
+ gridConfig: LayoutNode.Grid,
+ card: ThemeCard,
+ listState: LazyGridState,
+ modifier: Modifier = Modifier,
+ onItemClick: (LibraryItem) -> Unit = {},
+ onItemFocus: (LibraryItem) -> Unit = {},
+ bindingProvider: (LibraryItem) -> Map = { emptyMap() },
+ themePath: String? = null,
+) {
+ // String resolver for @string/ references
+ val context = LocalContext.current
+ val stringResolver = remember(themePath) {
+ ThemeStringResolver(context, context.assets)
+ }
+
+ // Spatial focus manager for directional navigation
+ val spatialFocusManager = LocalSpatialFocusManager.current
+
+ // Focus requesters for grid items - keyed by item index
+ // Using a map allows us to track focus requesters for visible items
+ val itemFocusRequesters = remember { mutableStateMapOf() }
+
+ // Track screen positions of grid items for dynamic focus calculation
+ val itemPositions = remember { mutableStateMapOf() }
+
+ // Use configured navigationId or default to "grid"
+ val gridNavId = gridConfig.navigationId ?: "grid"
+
+ // Coroutine scope for scrolling
+ val coroutineScope = rememberCoroutineScope()
+
+ // Use content padding from grid config, with defaults
+ val paddingTop = if (gridConfig.contentPaddingTop > 0) gridConfig.contentPaddingTop.dp else 80.dp
+ val paddingBottom = if (gridConfig.contentPaddingBottom > 0) gridConfig.contentPaddingBottom.dp else 72.dp
+ val paddingStart = if (gridConfig.contentPaddingStart > 0) gridConfig.contentPaddingStart.dp else 16.dp
+ val paddingEnd = if (gridConfig.contentPaddingEnd > 0) gridConfig.contentPaddingEnd.dp else 16.dp
+
+ // Use BoxWithConstraints to support percentage-based cell sizes and adaptive layouts
+ BoxWithConstraints(modifier = modifier.fillMaxSize()) {
+ val viewportWidth = maxWidth
+ val viewportHeight = maxHeight
+
+ val hSpacing = gridConfig.hSpacing.dp
+ val vSpacing = gridConfig.vSpacing.dp
+
+ // Calculate minimum cell width from config
+ val minCellWidthDp = dimToDp(gridConfig.cellWidth, viewportWidth, viewportHeight)
+
+ // Calculate available width for content (excluding padding)
+ val availableWidth = viewportWidth - paddingStart - paddingEnd
+
+ // Calculate how many columns fit, ensuring at least 1
+ val columnCount = maxOf(1, ((availableWidth + hSpacing) / (minCellWidthDp + hSpacing)).toInt())
+
+ // Calculate actual cell width to fill the available space evenly
+ // Formula: availableWidth = columnCount * cellWidth + (columnCount - 1) * spacing
+ // Solving for cellWidth: cellWidth = (availableWidth - (columnCount - 1) * spacing) / columnCount
+ val actualCellWidthDp = if (columnCount > 1) {
+ (availableWidth - hSpacing * (columnCount - 1)) / columnCount
+ } else {
+ availableWidth // Single column fills entire width
+ }
+
+ // Calculate cell height:
+ // 1. If aspectRatio is specified, use it to calculate from actual cell width
+ // 2. Else if cellHeight is specified, use it
+ // 3. Else fall back to card's canvas height
+ val cellHeightDp = when {
+ gridConfig.aspectRatio != null -> actualCellWidthDp / gridConfig.aspectRatio
+ gridConfig.cellHeight != null -> dimToDp(gridConfig.cellHeight, viewportWidth, viewportHeight)
+ else -> dimToDp(card.canvas.height, viewportWidth, viewportHeight)
+ }
+
+ // Calculate separator height if present (content height + margins)
+ val separatorContentHeightDp = gridConfig.separator?.let {
+ dimToDp(it.height, viewportWidth, viewportHeight)
+ } ?: 0.dp
+ val separatorTotalHeightDp = gridConfig.separator?.let {
+ separatorContentHeightDp + it.marginTop.dp + it.marginBottom.dp
+ } ?: 0.dp
+
+ // Total cell height including separator
+ val totalCellHeight = cellHeightDp + separatorTotalHeightDp
+
+ // Navigation links for edge navigation
+ val navigationLinks = SpatialFocusManager.NavigationLinks(
+ up = gridConfig.navigateUp,
+ down = gridConfig.navigateDown,
+ left = gridConfig.navigateLeft,
+ right = gridConfig.navigateRight,
+ )
+
+ // Helper to check if we're at edge and should use explicit navigation
+ fun isAtRightEdge(index: Int): Boolean = (index + 1) % columnCount == 0
+ fun isAtLeftEdge(index: Int): Boolean = index % columnCount == 0
+ fun isAtTopEdge(index: Int): Boolean = index < columnCount
+ fun isAtBottomEdge(index: Int, totalItems: Int): Boolean {
+ val lastRowStart = (totalItems - 1) / columnCount * columnCount
+ return index >= lastRowStart
+ }
+
+ // Dynamic focus handler that finds the closest visible item to the source element
+ val dynamicFocusHandler = remember(itemPositions, itemFocusRequesters, listState) {
+ SpatialFocusManager.DynamicFocusHandler { sourceBounds ->
+ // Calculate source center
+ val sourceCenterX = sourceBounds.left + sourceBounds.width / 2
+ val sourceCenterY = sourceBounds.top + sourceBounds.height / 2
+
+ // Get currently visible items from LazyGridState
+ val visibleItems = listState.layoutInfo.visibleItemsInfo
+ if (visibleItems.isEmpty()) {
+ Timber.tag("GridFocus").d("No visible items")
+ return@DynamicFocusHandler false
+ }
+
+ Timber.tag("GridFocus").d("Finding closest item to source center ($sourceCenterX, $sourceCenterY), ${visibleItems.size} visible items")
+
+ // Find the visible item whose center is closest to the source center
+ var closestIndex: Int? = null
+ var closestDistance = Float.MAX_VALUE
+
+ for (visibleItem in visibleItems) {
+ val itemIndex = visibleItem.index
+ val itemBounds = itemPositions[itemIndex]
+
+ if (itemBounds != null) {
+ val itemCenterX = itemBounds.left + itemBounds.width / 2
+ val itemCenterY = itemBounds.top + itemBounds.height / 2
+
+ // Calculate distance (using squared distance to avoid sqrt)
+ val dx = itemCenterX - sourceCenterX
+ val dy = itemCenterY - sourceCenterY
+ val distance = dx * dx + dy * dy
+
+ Timber.tag("GridFocus").d("Item $itemIndex: center=($itemCenterX, $itemCenterY), distance=$distance")
+
+ if (distance < closestDistance) {
+ closestDistance = distance
+ closestIndex = itemIndex
+ }
+ }
+ }
+
+ if (closestIndex != null) {
+ val focusRequester = itemFocusRequesters[closestIndex]
+ if (focusRequester != null) {
+ Timber.tag("GridFocus").d("Focusing item $closestIndex (distance=$closestDistance)")
+ try {
+ focusRequester.requestFocus()
+ return@DynamicFocusHandler true
+ } catch (e: Exception) {
+ Timber.tag("GridFocus").e(e, "Failed to focus item $closestIndex")
+ }
+ } else {
+ Timber.tag("GridFocus").d("No focus requester for item $closestIndex")
+ }
+ }
+
+ false
+ }
+ }
+
+ // Fallback focus requester (for first item or default focus)
+ val fallbackFocusRequester = remember { FocusRequester() }
+
+ // Use Fixed columns for precise control, since we've calculated the exact count
+ LazyVerticalGrid(
+ columns = GridCells.Fixed(columnCount),
+ state = listState,
+ modifier = Modifier
+ .fillMaxSize()
+ // Register the grid with spatial focus manager with dynamic focus handler
+ .onGloballyPositioned { coordinates ->
+ spatialFocusManager?.register(
+ id = gridNavId,
+ bounds = coordinates.boundsInRoot(),
+ focusRequester = fallbackFocusRequester,
+ navigationLinks = navigationLinks,
+ dynamicFocusHandler = dynamicFocusHandler,
+ )
+ }
+ .onFocusChanged { focusState ->
+ if (focusState.hasFocus) {
+ spatialFocusManager?.setFocused(gridNavId)
+ }
+ },
+ contentPadding = PaddingValues(
+ start = paddingStart,
+ end = paddingEnd,
+ top = paddingTop,
+ bottom = paddingBottom,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(hSpacing),
+ verticalArrangement = Arrangement.spacedBy(vSpacing),
+ ) {
+ itemsIndexed(
+ items = items,
+ key = { _, item -> item.appId }
+ ) { index, item ->
+ // Get or create focus requester for this item
+ val itemFocusRequester = remember {
+ itemFocusRequesters.getOrPut(index) { FocusRequester() }
+ }
+
+ Column {
+ ThemedGameTile(
+ item = item,
+ card = card,
+ bindings = bindingProvider(item),
+ cellSize = DpSize(actualCellWidthDp, cellHeightDp),
+ onClick = { onItemClick(item) },
+ onFocus = { onItemFocus(item) },
+ stringResolver = stringResolver,
+ themePath = themePath,
+ highlightBorderWidth = gridConfig.highlightBorderWidth,
+ highlightCornerRadius = gridConfig.highlightCornerRadius,
+ scrollTopMargin = paddingTop,
+ // Pass focus requester for all items (enables dynamic focus)
+ focusRequester = itemFocusRequester,
+ // Callback to track item position for dynamic focus calculation
+ onPositioned = { bounds ->
+ itemPositions[index] = bounds
+ },
+ // Edge navigation
+ onEdgeNavigation = { direction ->
+ val target = when (direction) {
+ SpatialFocusManager.Direction.RIGHT ->
+ if (isAtRightEdge(index)) navigationLinks.right else null
+ SpatialFocusManager.Direction.LEFT ->
+ if (isAtLeftEdge(index)) navigationLinks.left else null
+ SpatialFocusManager.Direction.UP ->
+ if (isAtTopEdge(index)) navigationLinks.up else null
+ SpatialFocusManager.Direction.DOWN ->
+ if (isAtBottomEdge(index, items.size)) navigationLinks.down else null
+ }
+ if (target != null) {
+ spatialFocusManager?.navigateTo(target) ?: false
+ } else {
+ false
+ }
+ },
+ )
+ // Render separator if configured
+ gridConfig.separator?.let { separator ->
+ SeparatorView(
+ separator = separator,
+ width = actualCellWidthDp,
+ contentHeight = separatorContentHeightDp,
+ stringResolver = stringResolver,
+ themePath = themePath,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * A themed game carousel that renders library items in a center-focused pager.
+ * Supports both horizontal and vertical orientations.
+ * Features:
+ * - Center-focused scrolling with snap-to-center behavior
+ * - Highlighted/focused item scales up
+ * - Configurable alignment within container
+ * - Supports touch swipe and controller navigation
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ThemedGameCarousel(
+ items: List,
+ carouselConfig: LayoutNode.Carousel,
+ card: ThemeCard,
+ modifier: Modifier = Modifier,
+ initialPage: Int = 0,
+ onPageChanged: (Int) -> Unit = {},
+ onItemClick: (LibraryItem) -> Unit = {},
+ onItemFocus: (LibraryItem) -> Unit = {},
+ bindingProvider: (LibraryItem) -> Map = { emptyMap() },
+ themePath: String? = null,
+) {
+ val context = LocalContext.current
+ val stringResolver = remember(themePath) {
+ ThemeStringResolver(context, context.assets)
+ }
+
+ val isVertical = carouselConfig.orientation == CarouselOrientation.VERTICAL
+
+ BoxWithConstraints(modifier = modifier.fillMaxSize()) {
+ val viewportWidth = maxWidth
+ val viewportHeight = maxHeight
+
+ // Calculate item dimensions
+ val itemWidth = dimToDp(carouselConfig.itemSize.width, viewportWidth, viewportHeight)
+ val itemHeight = dimToDp(carouselConfig.itemSize.height, viewportWidth, viewportHeight)
+ val spacing = carouselConfig.itemSpacing.dp
+ val focusedScale = carouselConfig.focusedScale
+
+ // Maximum scaled size for layout calculations
+ val scaledItemWidth = itemWidth * focusedScale
+ val scaledItemHeight = itemHeight * focusedScale
+
+ // Clamp initial page to valid range
+ val safeInitialPage = initialPage.coerceIn(0, (items.size - 1).coerceAtLeast(0))
+
+ val pagerState = rememberPagerState(
+ initialPage = safeInitialPage,
+ pageCount = { items.size }
+ )
+
+ // Coroutine scope for animating page changes
+ val coroutineScope = rememberCoroutineScope()
+
+ // Focus requester for controller navigation
+ val focusRequester = remember { FocusRequester() }
+
+ // Spatial focus manager for directional navigation based on screen position
+ val spatialFocusManager = LocalSpatialFocusManager.current
+
+ // Use configured navigationId or default to "carousel"
+ val carouselNavId = carouselConfig.navigationId ?: "carousel"
+
+ // Track if carousel has focus (for fading effect)
+ // Default to TRUE - carousel is considered focused until explicitly unfocused
+ var carouselHasFocus by remember { mutableStateOf(true) }
+
+ // Track if carousel has ever had focus (to know if unfocus is intentional)
+ var hasEverHadFocus by remember { mutableStateOf(false) }
+
+ // Animate carousel opacity based on focus state
+ // Only fade if we've had focus before and now lost it
+ val carouselAlpha by animateFloatAsState(
+ targetValue = if (carouselHasFocus || !hasEverHadFocus) 1f else 0.5f,
+ animationSpec = tween(durationMillis = 200),
+ label = "carouselFocusAlpha"
+ )
+
+ // Request focus when carousel is first displayed
+ LaunchedEffect(Unit) {
+ // Small delay to ensure layout is complete
+ kotlinx.coroutines.delay(100)
+ try {
+ focusRequester.requestFocus()
+ } catch (e: Exception) {
+ // Focus request can fail if not attached yet, ignore
+ }
+ }
+
+ // Notify when focused item changes and save position
+ LaunchedEffect(pagerState.currentPage) {
+ items.getOrNull(pagerState.currentPage)?.let { onItemFocus(it) }
+ onPageChanged(pagerState.currentPage)
+ }
+
+ // Focused background image with crossfade transition
+ carouselConfig.focusedBackground?.let { bgBinding ->
+ val focusedItem = items.getOrNull(pagerState.currentPage)
+ val focusedBindings = focusedItem?.let { bindingProvider(it) } ?: emptyMap()
+ val bgImageUrl = resolveStringBinding(bgBinding, focusedBindings)
+
+ // Preload adjacent images for smoother transitions
+ val prevItem = items.getOrNull(pagerState.currentPage - 1)
+ val nextItem = items.getOrNull(pagerState.currentPage + 1)
+ val prevUrl = prevItem?.let { resolveStringBinding(bgBinding, bindingProvider(it)) }
+ val nextUrl = nextItem?.let { resolveStringBinding(bgBinding, bindingProvider(it)) }
+
+ PreloadImages(urls = listOfNotNull(prevUrl, nextUrl))
+
+ CarouselBackgroundImage(
+ imageUrl = bgImageUrl,
+ opacity = carouselConfig.backgroundOpacity,
+ transitionSpeed = carouselConfig.backgroundTransitionSpeed,
+ )
+ }
+
+ // Calculate offsets first (needed for content padding calculation)
+ val verticalOffset = dimToDp(carouselConfig.verticalOffset, viewportWidth, viewportHeight)
+ val horizontalOffset = dimToDp(carouselConfig.horizontalOffset, viewportWidth, viewportHeight)
+
+ // Calculate content padding based on alignment and centerFocus settings
+ // For centerFocus=true or horizontalAlign=center: center items
+ // For centerFocus=false with horizontalAlign=start: put focused item at start (with offset)
+ // For centerFocus=false with horizontalAlign=end: put focused item at end
+ // Note: All padding values must be coerced to non-negative to avoid crashes
+ val horizontalContentPadding = when {
+ carouselConfig.centerFocus || carouselConfig.horizontalAlign == HorizontalAlign.CENTER -> {
+ ((viewportWidth - itemWidth) / 2).coerceAtLeast(0.dp)
+ }
+ carouselConfig.horizontalAlign == HorizontalAlign.START -> {
+ // Small padding at start, large padding at end to show items to the right
+ horizontalOffset.coerceAtLeast(16.dp)
+ }
+ carouselConfig.horizontalAlign == HorizontalAlign.END -> {
+ // Large padding at start, small at end to show items to the left
+ (viewportWidth - itemWidth - horizontalOffset.coerceAtLeast(16.dp)).coerceAtLeast(0.dp)
+ }
+ else -> ((viewportWidth - itemWidth) / 2).coerceAtLeast(0.dp)
+ }
+ val horizontalEndPadding = when {
+ carouselConfig.centerFocus || carouselConfig.horizontalAlign == HorizontalAlign.CENTER -> {
+ horizontalContentPadding
+ }
+ carouselConfig.horizontalAlign == HorizontalAlign.START -> {
+ // End padding to allow scrolling through all items
+ (viewportWidth - itemWidth - horizontalOffset.coerceAtLeast(16.dp)).coerceAtLeast(0.dp)
+ }
+ carouselConfig.horizontalAlign == HorizontalAlign.END -> {
+ horizontalOffset.coerceAtLeast(16.dp)
+ }
+ else -> horizontalContentPadding
+ }
+ val verticalContentPadding = ((viewportHeight - itemHeight) / 2).coerceAtLeast(0.dp)
+
+ // Alignment based on config
+ val verticalArrangement = when (carouselConfig.verticalAlign) {
+ VerticalAlign.CENTER -> Arrangement.Center
+ VerticalAlign.BOTTOM -> Arrangement.Bottom
+ VerticalAlign.TOP -> Arrangement.Top
+ }
+
+ val horizontalArrangement = when (carouselConfig.horizontalAlign) {
+ HorizontalAlign.CENTER -> Arrangement.Center
+ HorizontalAlign.END -> Arrangement.End
+ HorizontalAlign.START -> Arrangement.Start
+ }
+
+ // Helper to navigate using explicit target or spatial navigation
+ fun navigateToTarget(explicitTarget: String?, direction: SpatialFocusManager.Direction): Boolean {
+ return if (explicitTarget != null) {
+ spatialFocusManager?.navigateTo(explicitTarget) ?: false
+ } else {
+ spatialFocusManager?.navigateInDirection(carouselNavId, direction) ?: false
+ }
+ }
+
+ // Key event handler - swap primary/secondary directions based on orientation
+ val keyEventHandler: (androidx.compose.ui.input.key.KeyEvent) -> Boolean = { keyEvent ->
+ if (keyEvent.type == KeyEventType.KeyDown) {
+ when (keyEvent.key) {
+ // Primary scroll direction (Left/Right for horizontal, Up/Down for vertical)
+ Key.DirectionLeft -> {
+ if (!isVertical && pagerState.currentPage > 0) {
+ coroutineScope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage - 1)
+ }
+ true
+ } else if (isVertical || pagerState.currentPage == 0) {
+ // For vertical carousel or at start of horizontal, navigate left
+ navigateToTarget(carouselConfig.navigateLeft, SpatialFocusManager.Direction.LEFT)
+ } else false
+ }
+ Key.DirectionRight -> {
+ if (!isVertical && pagerState.currentPage < items.size - 1) {
+ coroutineScope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage + 1)
+ }
+ true
+ } else if (isVertical || pagerState.currentPage >= items.size - 1) {
+ // For vertical carousel or at end of horizontal, navigate right
+ navigateToTarget(carouselConfig.navigateRight, SpatialFocusManager.Direction.RIGHT)
+ } else false
+ }
+ Key.DirectionUp -> {
+ if (isVertical && pagerState.currentPage > 0) {
+ coroutineScope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage - 1)
+ }
+ true
+ } else if (!isVertical || pagerState.currentPage == 0) {
+ // For horizontal carousel or at start of vertical, navigate up
+ navigateToTarget(carouselConfig.navigateUp, SpatialFocusManager.Direction.UP)
+ } else false
+ }
+ Key.DirectionDown -> {
+ if (isVertical && pagerState.currentPage < items.size - 1) {
+ coroutineScope.launch {
+ pagerState.animateScrollToPage(pagerState.currentPage + 1)
+ }
+ true
+ } else if (!isVertical || pagerState.currentPage >= items.size - 1) {
+ // For horizontal carousel or at end of vertical, navigate down
+ navigateToTarget(carouselConfig.navigateDown, SpatialFocusManager.Direction.DOWN)
+ } else false
+ }
+ Key.Enter, Key.DirectionCenter, Key.ButtonA -> {
+ items.getOrNull(pagerState.currentPage)?.let { onItemClick(it) }
+ true
+ }
+ else -> false
+ }
+ } else {
+ false
+ }
+ }
+
+ // Focus change handler
+ // Use hasFocus (not isFocused) because focus might be on a child element
+ val focusChangeHandler: (androidx.compose.ui.focus.FocusState) -> Unit = { focusState ->
+ val nowHasFocus = focusState.hasFocus || focusState.isFocused
+ carouselHasFocus = nowHasFocus
+ if (nowHasFocus) {
+ hasEverHadFocus = true
+ spatialFocusManager?.setFocused(carouselNavId)
+ }
+ }
+
+ // Get focused item offsets from config
+ val focusedOffsetX = carouselConfig.focusedOffsetX.dp
+ val focusedOffsetY = carouselConfig.focusedOffsetY.dp
+ val focusedSpacing = carouselConfig.focusedSpacing.dp
+ val beforeFocusOffset = carouselConfig.beforeFocusOffset.dp
+
+ // Render pager content
+ val renderPageContent: @Composable (Int) -> Unit = { page ->
+ val item = items.getOrNull(page)
+ if (item != null) {
+ val bindings = bindingProvider(item)
+
+ // Calculate signed offset from current page (negative = before, positive = after)
+ val signedPageOffset = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
+
+ // Calculate absolute distance for scale/alpha calculations
+ val pageOffset = signedPageOffset.absoluteValue.coerceIn(0f, 1f)
+
+ // Scale from focusedScale (at center) to 1.0 (at edges)
+ val scale = lerp(
+ start = focusedScale,
+ stop = 1f,
+ fraction = pageOffset
+ )
+
+ // Fade non-focused items based on unfocusedAlpha setting (1.0 = no fade)
+ val alpha = lerp(
+ start = 1f,
+ stop = carouselConfig.unfocusedAlpha,
+ fraction = pageOffset
+ )
+
+ // Z-index: focused item (pageOffset=0) gets highest value
+ val zIndex = 1f - pageOffset
+
+ // Determine if this page is focused:
+ // - Must be near center (pageOffset < 0.5)
+ // - Carousel must have focus (otherwise user navigated away to other elements)
+ val isFocused = pageOffset < 0.5f && carouselHasFocus
+
+ // Calculate focused offset (smoothly interpolated based on focus)
+ val focusProgress = 1f - pageOffset
+ val offsetX = focusedOffsetX * focusProgress
+ val offsetY = focusedOffsetY * focusProgress
+
+ // Calculate spacing offset to push items away from the focused item
+ // Uses signed offset directly for smooth, continuous transitions:
+ // - signedPageOffset > 0 (item before focus) → push backward (negative)
+ // - signedPageOffset < 0 (item after focus) → push forward (positive)
+ // - signedPageOffset = 0 (focused item) → no push
+ // Clamp to [-1, 1] so items far away don't get excessive push
+ val clampedSignedOffset = signedPageOffset.coerceIn(-1f, 1f)
+ val spacingOffset = -focusedSpacing * clampedSignedOffset
+
+ // Additional offset for items before focus (positive signedPageOffset = before)
+ // This pushes items on the left further away for asymmetric layouts
+ val beforeOffset = if (signedPageOffset > 0) {
+ -beforeFocusOffset * clampedSignedOffset.coerceAtLeast(0f)
+ } else 0.dp
+
+ // Apply spacing offset based on carousel orientation
+ val finalOffsetX = offsetX + if (!isVertical) (spacingOffset + beforeOffset) else 0.dp
+ val finalOffsetY = offsetY + if (isVertical) (spacingOffset + beforeOffset) else 0.dp
+
+ Box(
+ modifier = Modifier
+ .zIndex(zIndex)
+ .size(itemWidth, itemHeight)
+ .offset(x = finalOffsetX, y = finalOffsetY)
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ this.alpha = alpha
+ }
+ .clickable { onItemClick(item) }
+ .then(
+ // Show focus border if highlightBorderWidth > 0
+ if (isFocused && carouselConfig.highlightBorderWidth > 0f) {
+ Modifier.border(
+ width = carouselConfig.highlightBorderWidth.dp,
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f),
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
+ )
+ ),
+ shape = RoundedCornerShape(carouselConfig.highlightCornerRadius.dp)
+ )
+ } else Modifier
+ ),
+ ) {
+ // Render each layer from the card, sorted by zIndex then declarationOrder
+ val isPortrait = rememberIsPortrait()
+ card.layers
+ .filter { layer -> layer.visibility.isVisible(isPortrait) }
+ .sortedWith(compareBy { it.zIndex }.thenBy { it.declarationOrder })
+ .forEach { layer ->
+ RenderCarouselLayer(
+ layer = layer,
+ bindings = bindings,
+ parentSize = DpSize(itemWidth, itemHeight),
+ stringResolver = stringResolver,
+ themePath = themePath,
+ isFocused = isFocused,
+ focusProgress = 1f - pageOffset,
+ )
+ }
+ }
+ }
+ }
+
+ if (isVertical) {
+ // Vertical carousel layout
+ Row(
+ modifier = Modifier
+ .fillMaxSize()
+ .offset(x = horizontalOffset, y = verticalOffset)
+ .focusRequester(focusRequester)
+ .onFocusChanged(focusChangeHandler)
+ .focusable()
+ .graphicsLayer { alpha = carouselAlpha }
+ .onKeyEvent(keyEventHandler),
+ horizontalArrangement = horizontalArrangement,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ VerticalPager(
+ state = pagerState,
+ modifier = Modifier
+ .width(scaledItemWidth)
+ .fillMaxHeight()
+ .onGloballyPositioned { coordinates ->
+ spatialFocusManager?.register(
+ id = carouselNavId,
+ bounds = coordinates.boundsInRoot(),
+ focusRequester = focusRequester
+ )
+ },
+ contentPadding = PaddingValues(vertical = verticalContentPadding),
+ pageSpacing = spacing,
+ flingBehavior = PagerDefaults.flingBehavior(state = pagerState),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ userScrollEnabled = true,
+ ) { page ->
+ renderPageContent(page)
+ }
+ }
+ } else {
+ // Horizontal carousel layout (original behavior)
+ // Only apply horizontalOffset for centered alignment (start/end use content padding)
+ val effectiveHorizontalOffset = if (carouselConfig.centerFocus || carouselConfig.horizontalAlign == HorizontalAlign.CENTER) {
+ horizontalOffset
+ } else {
+ 0.dp
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .offset(x = effectiveHorizontalOffset, y = verticalOffset)
+ .focusRequester(focusRequester)
+ .onFocusChanged(focusChangeHandler)
+ .focusable()
+ .graphicsLayer { alpha = carouselAlpha }
+ .onKeyEvent(keyEventHandler),
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(scaledItemHeight)
+ .onGloballyPositioned { coordinates ->
+ spatialFocusManager?.register(
+ id = carouselNavId,
+ bounds = coordinates.boundsInRoot(),
+ focusRequester = focusRequester
+ )
+ },
+ contentPadding = PaddingValues(start = horizontalContentPadding, end = horizontalEndPadding),
+ pageSpacing = spacing,
+ flingBehavior = PagerDefaults.flingBehavior(state = pagerState),
+ verticalAlignment = Alignment.CenterVertically,
+ userScrollEnabled = true,
+ ) { page ->
+ renderPageContent(page)
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Resolves a StringOrBinding to an actual string value using the provided bindings map.
+ */
+private fun resolveStringBinding(binding: StringOrBinding, bindings: Map): String? {
+ return when (binding) {
+ is StringOrBinding.Literal -> binding.value
+ is StringOrBinding.Ref -> bindings[binding.binding.path]
+ }
+}
+
+/**
+ * Preloads images into Coil's cache for smoother transitions.
+ */
+@Composable
+private fun PreloadImages(urls: List) {
+ val context = LocalContext.current
+ val imageLoader = remember { coil.ImageLoader(context) }
+
+ LaunchedEffect(urls) {
+ urls.forEach { url ->
+ if (url.isNotBlank()) {
+ val request = coil.request.ImageRequest.Builder(context)
+ .data(url)
+ .build()
+ imageLoader.enqueue(request)
+ }
+ }
+ }
+}
+
+/**
+ * Full-screen background image with crossfade transition.
+ * Used by carousels to show the focused item's hero image behind the content.
+ */
+@Composable
+private fun BoxScope.CarouselBackgroundImage(
+ imageUrl: String?,
+ opacity: Float,
+ transitionSpeed: Int,
+) {
+ // Crossfade between different images when URL changes
+ Crossfade(
+ targetState = imageUrl,
+ animationSpec = tween(durationMillis = transitionSpeed),
+ modifier = Modifier.matchParentSize(),
+ label = "backgroundCrossfade"
+ ) { url ->
+ if (url != null) {
+ CoilImage(
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer { alpha = opacity },
+ imageModel = { url },
+ imageOptions = ImageOptions(
+ contentScale = ContentScale.Crop,
+ contentDescription = null,
+ ),
+ )
+ }
+ }
+}
+
+/**
+ * Render a single theme layer for grid items.
+ * Handles focusOnly layers with fade transitions.
+ */
+@Composable
+private fun BoxScope.RenderGridLayer(
+ layer: Layer,
+ bindings: Map,
+ parentSize: DpSize,
+ stringResolver: ThemeStringResolver,
+ themePath: String?,
+ isFocused: Boolean,
+ onImageLoadFailed: () -> Unit = {},
+) {
+ // Check conditional visibility based on binding value
+ layer.visibleWhen?.let { bindingPath ->
+ val bindingValue = bindings[bindingPath] ?: "false"
+ if (bindingValue != "true") return
+ }
+
+ // If layer is focusOnly, animate based on focus state
+ if (layer.focusOnly) {
+ val targetAlpha = if (isFocused) 1f else 0f
+ val animatedAlpha by animateFloatAsState(
+ targetValue = targetAlpha,
+ animationSpec = tween(durationMillis = layer.focusTransitionSpeed),
+ label = "gridFocusOnlyAlpha"
+ )
+
+ // Skip rendering entirely if invisible
+ if (animatedAlpha <= 0.01f) return
+
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .graphicsLayer { alpha = animatedAlpha }
+ ) {
+ RenderThemedLayer(
+ layer = layer,
+ bindings = bindings,
+ parentSize = parentSize,
+ onImageLoadFailed = onImageLoadFailed,
+ stringResolver = stringResolver,
+ themePath = themePath,
+ )
+ }
+ } else {
+ // Regular layer, render normally
+ RenderThemedLayer(
+ layer = layer,
+ bindings = bindings,
+ parentSize = parentSize,
+ onImageLoadFailed = onImageLoadFailed,
+ stringResolver = stringResolver,
+ themePath = themePath,
+ )
+ }
+}
+
+/**
+ * Render a single theme layer for carousel items.
+ * Handles focusOnly layers with fade transitions.
+ */
+@Composable
+private fun BoxScope.RenderCarouselLayer(
+ layer: Layer,
+ bindings: Map,
+ parentSize: DpSize,
+ stringResolver: ThemeStringResolver,
+ themePath: String?,
+ isFocused: Boolean,
+ focusProgress: Float, // 0 to 1, where 1 = fully focused
+) {
+ // Check conditional visibility based on binding value
+ layer.visibleWhen?.let { bindingPath ->
+ val bindingValue = bindings[bindingPath] ?: "false"
+ if (bindingValue != "true") return
+ }
+
+ // If layer is focusOnly and not focused, animate opacity
+ if (layer.focusOnly) {
+ // Animate the alpha based on focus state
+ val targetAlpha = if (isFocused) 1f else 0f
+ val animatedAlpha by animateFloatAsState(
+ targetValue = targetAlpha,
+ animationSpec = tween(durationMillis = layer.focusTransitionSpeed),
+ label = "focusOnlyAlpha"
+ )
+
+ // Skip rendering entirely if invisible
+ if (animatedAlpha <= 0.01f) return
+
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .graphicsLayer { alpha = animatedAlpha }
+ ) {
+ RenderThemedLayer(
+ layer = layer,
+ bindings = bindings,
+ parentSize = parentSize,
+ onImageLoadFailed = {},
+ stringResolver = stringResolver,
+ themePath = themePath,
+ )
+ }
+ } else {
+ // Regular layer, render normally
+ RenderThemedLayer(
+ layer = layer,
+ bindings = bindings,
+ parentSize = parentSize,
+ onImageLoadFailed = {},
+ stringResolver = stringResolver,
+ themePath = themePath,
+ )
+ }
+}
+
+/**
+ * Renders a separator between grid items.
+ * Layers are rendered without game bindings (static content only).
+ */
+@Composable
+private fun SeparatorView(
+ separator: GridSeparator,
+ width: Dp,
+ contentHeight: Dp,
+ stringResolver: ThemeStringResolver,
+ themePath: String?,
+) {
+ // Apply margins
+ val marginTop = separator.marginTop.dp
+ val marginBottom = separator.marginBottom.dp
+ val marginStart = separator.marginStart.dp
+ val marginEnd = separator.marginEnd.dp
+
+ // Content area size (excluding margins)
+ val contentWidth = width - marginStart - marginEnd
+ val parentSize = DpSize(contentWidth, contentHeight)
+
+ // Empty bindings - separator doesn't have access to game data
+ val emptyBindings = emptyMap()
+
+ // Total height includes margins
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = marginTop, bottom = marginBottom, start = marginStart, end = marginEnd)
+ ) {
+ Box(
+ modifier = Modifier.size(contentWidth, contentHeight)
+ ) {
+ // Get current orientation for visibility filtering (centralized)
+ val isPortrait = rememberIsPortrait()
+
+ // Sort layers by zIndex then declarationOrder for proper stacking
+ separator.layers
+ .filter { layer -> layer.visibility.isVisible(isPortrait) }
+ .sortedWith(compareBy { it.zIndex }.thenBy { it.declarationOrder })
+ .forEach { layer ->
+ RenderThemedLayer(
+ layer = layer,
+ bindings = emptyBindings,
+ parentSize = parentSize,
+ stringResolver = stringResolver,
+ themePath = themePath,
+ )
+ }
+ }
+ }
+}
+
+/**
+ * A single game tile rendered using theme card layers.
+ */
+@Composable
+private fun ThemedGameTile(
+ item: LibraryItem,
+ card: ThemeCard,
+ bindings: Map,
+ cellSize: DpSize,
+ onClick: () -> Unit,
+ onFocus: () -> Unit,
+ stringResolver: ThemeStringResolver,
+ themePath: String?,
+ highlightBorderWidth: Float = 3f,
+ highlightCornerRadius: Float = 8f,
+ scrollTopMargin: Dp = 0.dp,
+ focusRequester: FocusRequester? = null,
+ onPositioned: ((Rect) -> Unit)? = null,
+ onEdgeNavigation: (SpatialFocusManager.Direction) -> Boolean = { false },
+) {
+ var isFocused by remember { mutableStateOf(false) }
+ var imageLoadFailed by remember { mutableStateOf(false) }
+
+ // For scrolling focused items into view (with margin for top bar)
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ val coroutineScope = rememberCoroutineScope()
+ val density = LocalDensity.current
+ var itemHeight by remember { mutableStateOf(0f) }
+
+ Box(
+ modifier = Modifier
+ .size(cellSize.width, cellSize.height)
+ .bringIntoViewRequester(bringIntoViewRequester)
+ .onGloballyPositioned { coordinates ->
+ itemHeight = coordinates.size.height.toFloat()
+ // Track position for dynamic focus calculation
+ onPositioned?.invoke(coordinates.boundsInRoot())
+ }
+ // Apply focus requester if provided
+ .then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
+ .onFocusChanged { focusState ->
+ isFocused = focusState.isFocused
+ if (isFocused) {
+ onFocus()
+ // Scroll into view with extra margin for top bar (if any)
+ coroutineScope.launch {
+ val topMarginPx = with(density) { scrollTopMargin.toPx() }
+ val bottomMargin = 60f
+ bringIntoViewRequester.bringIntoView(
+ Rect(-topMarginPx, -topMarginPx, 0f, itemHeight + bottomMargin)
+ )
+ }
+ }
+ }
+ .onKeyEvent { keyEvent ->
+ if (keyEvent.type == KeyEventType.KeyDown) {
+ val direction = when (keyEvent.key) {
+ Key.DirectionRight -> SpatialFocusManager.Direction.RIGHT
+ Key.DirectionLeft -> SpatialFocusManager.Direction.LEFT
+ Key.DirectionUp -> SpatialFocusManager.Direction.UP
+ Key.DirectionDown -> SpatialFocusManager.Direction.DOWN
+ else -> null
+ }
+ if (direction != null) {
+ onEdgeNavigation(direction)
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ }
+ .clickable(
+ onClick = onClick,
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ )
+ .then(
+ // Show focus border if highlightBorderWidth > 0
+ if (isFocused && highlightBorderWidth > 0f) {
+ Modifier.border(
+ width = highlightBorderWidth.dp,
+ brush = Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f),
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.7f),
+ )
+ ),
+ shape = RoundedCornerShape(highlightCornerRadius.dp)
+ )
+ } else Modifier
+ )
+ ) {
+ // Get current orientation for visibility filtering (centralized)
+ val isPortrait = rememberIsPortrait()
+
+ // Render each layer from the card, filtering by visibility, sorted by zIndex then declarationOrder
+ card.layers
+ .filter { layer -> layer.visibility.isVisible(isPortrait) }
+ .sortedWith(compareBy { it.zIndex }.thenBy { it.declarationOrder })
+ .forEach { layer ->
+ RenderGridLayer(
+ layer = layer,
+ bindings = bindings,
+ parentSize = cellSize,
+ stringResolver = stringResolver,
+ themePath = themePath,
+ isFocused = isFocused,
+ onImageLoadFailed = { imageLoadFailed = true },
+ )
+ }
+
+ // Fallback: Show title prominently if image failed
+ if (imageLoadFailed) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = item.name,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = androidx.compose.ui.text.style.TextAlign.Center,
+ modifier = Modifier.padding(8.dp)
+ )
+ }
+ }
+ }
+}
+
+/**
+ * Render a single theme layer with proper binding resolution.
+ */
+@Composable
+private fun BoxScope.RenderThemedLayer(
+ layer: Layer,
+ bindings: Map,
+ parentSize: DpSize,
+ onImageLoadFailed: () -> Unit = {},
+ stringResolver: ThemeStringResolver,
+ themePath: String?,
+) {
+ // Check conditional visibility based on binding value
+ layer.visibleWhen?.let { bindingPath ->
+ val bindingValue = bindings[bindingPath] ?: "false"
+ if (bindingValue != "true") return
+ }
+
+ when (layer) {
+ is Layer.ImageLayer -> {
+ val rawSrc = resolveBinding(layer.source.src, bindings)
+ // Resolve local theme assets (paths without protocol)
+ val src = if (rawSrc.isNotBlank() && !rawSrc.contains("://") && themePath != null) {
+ // Try the path as-is first (e.g., "assets/clock.png"), then with "assets/" prefix
+ val directFile = java.io.File(themePath, rawSrc)
+ val assetFile = java.io.File(themePath, "assets/$rawSrc")
+ when {
+ directFile.exists() -> "file://${directFile.absolutePath}"
+ assetFile.exists() -> "file://${assetFile.absolutePath}"
+ else -> rawSrc
+ }
+ } else rawSrc
+ val shape = parseCornerRadius(layer.cornerRadius)
+ val rawX = dimToDp(layer.position.x, parentSize.width, parentSize.height)
+ val rawY = dimToDp(layer.position.y, parentSize.width, parentSize.height)
+ val rawW = layer.size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val rawH = layer.size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+ val w = if (rawW == null || rawW == Dp.Unspecified) parentSize.width else rawW
+ val h = if (rawH == null || rawH == Dp.Unspecified) parentSize.height else rawH
+ val pos = calculateAnchoredPosition(rawX, rawY, w, h, parentSize.width, parentSize.height, layer.anchor)
+ val alpha = resolveFloatBinding(layer.opacity, 1f)
+
+ if (src.isNotBlank()) {
+ val contentScale = when (layer.scaleType.lowercase()) {
+ "contain", "fit" -> ContentScale.Fit
+ "stretch", "fill" -> ContentScale.FillBounds
+ else -> ContentScale.Crop // "cover" is default
+ }
+ Box(
+ modifier = Modifier
+ .zIndex(layer.zIndex)
+ .offset(x = pos.x, y = pos.y)
+ .size(w, h)
+ .clip(shape)
+ .alpha(alpha)
+ ) {
+ CoilImage(
+ modifier = Modifier.fillMaxSize(),
+ imageModel = { src },
+ imageOptions = ImageOptions(
+ contentScale = contentScale,
+ contentDescription = null,
+ ),
+ failure = {
+ onImageLoadFailed()
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFF444444))
+ )
+ },
+ loading = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0xFF333333))
+ )
+ }
+ )
+ }
+ } else {
+ // No image URL - show placeholder
+ Box(
+ modifier = Modifier
+ .zIndex(layer.zIndex)
+ .offset(x = pos.x, y = pos.y)
+ .size(w, h)
+ .clip(shape)
+ .background(Color(0xFF555555))
+ )
+ }
+ }
+
+ is Layer.RectLayer -> {
+ val rawX = dimToDp(layer.position.x, parentSize.width, parentSize.height)
+ val rawY = dimToDp(layer.position.y, parentSize.width, parentSize.height)
+ val rawW = layer.size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val rawH = layer.size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+ val w = if (rawW == null || rawW == Dp.Unspecified) parentSize.width else rawW
+ val h = if (rawH == null || rawH == Dp.Unspecified) parentSize.height else rawH
+ val pos = calculateAnchoredPosition(rawX, rawY, w, h, parentSize.width, parentSize.height, layer.anchor)
+ val shape = parseCornerRadius(layer.cornerRadius)
+ val fillColor = resolveIntBinding(layer.color, 0x66000000, bindings)
+ val alpha = resolveFloatBinding(layer.opacity, 1f)
+ val borderWidth = resolveFloatBinding(layer.borderWidth, 0f)
+ val borderColor = resolveIntBinding(layer.borderColor, 0xFFFFFFFF.toInt(), bindings)
+
+ // Create border brush - either gradient or solid color
+ val borderBrush = if (layer.borderGradient) {
+ Brush.verticalGradient(
+ colors = listOf(
+ MaterialTheme.colorScheme.tertiary,
+ MaterialTheme.colorScheme.primary,
+ )
+ )
+ } else null
+
+ Box(
+ modifier = Modifier
+ .zIndex(layer.zIndex)
+ .offset(x = pos.x, y = pos.y)
+ .size(w, h)
+ .clip(shape)
+ .alpha(alpha)
+ .background(Color(fillColor))
+ .then(
+ if (borderWidth > 0f) {
+ if (borderBrush != null) {
+ Modifier.border(borderWidth.dp, borderBrush, shape)
+ } else {
+ Modifier.border(borderWidth.dp, Color(borderColor), shape)
+ }
+ } else {
+ Modifier
+ }
+ )
+ )
+ }
+
+ is Layer.TextLayer -> {
+ val rawText = resolveBinding(layer.text, bindings)
+ val text = stringResolver.resolve(rawText, themePath)
+ val rawX = dimToDp(layer.position.x, parentSize.width, parentSize.height)
+ val rawY = dimToDp(layer.position.y, parentSize.width, parentSize.height)
+ // Handle Dp.Unspecified properly - use default if unspecified
+ val rawW = layer.size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val rawH = layer.size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+ // For anchor calculations, use 0.dp when unspecified so text positions correctly at anchor point
+ val wForAnchor = if (rawW == null || rawW == Dp.Unspecified) 0.dp else rawW
+ val hForAnchor = if (rawH == null || rawH == Dp.Unspecified) 0.dp else rawH
+ val pos = calculateAnchoredPosition(rawX, rawY, wForAnchor, hForAnchor, parentSize.width, parentSize.height, layer.anchor)
+ // For display, use explicit size if specified, otherwise let text wrap
+ val w = if (rawW == null || rawW == Dp.Unspecified) Dp.Unspecified else rawW
+ val h = if (rawH == null || rawH == Dp.Unspecified) Dp.Unspecified else rawH
+ val textSize = resolveFloatBinding(layer.textSize, 14f)
+ val color = resolveIntBinding(layer.color, 0xFFFFFFFF.toInt(), bindings)
+ val alpha = resolveFloatBinding(layer.opacity, 1f)
+
+ // Shadow properties
+ val shadowColorInt = resolveIntBinding(layer.shadowColor, 0, bindings)
+ val shadow = if (shadowColorInt != 0) {
+ Shadow(
+ color = Color(shadowColorInt),
+ offset = Offset(
+ resolveFloatBinding(layer.shadowOffsetX, 0f),
+ resolveFloatBinding(layer.shadowOffsetY, 0f)
+ ),
+ blurRadius = resolveFloatBinding(layer.shadowRadius, 0f)
+ )
+ } else null
+
+ // Treat textSize as sp directly (theme authors specify logical size)
+ val textSizeSp = textSize.sp
+
+ val textAlignment = when (layer.textAlign.lowercase()) {
+ "center" -> androidx.compose.ui.text.style.TextAlign.Center
+ "right" -> androidx.compose.ui.text.style.TextAlign.End
+ else -> androidx.compose.ui.text.style.TextAlign.Start
+ }
+ val boxAlignment = when (layer.textAlign.lowercase()) {
+ "center" -> Alignment.Center
+ "right" -> Alignment.CenterEnd
+ else -> Alignment.CenterStart
+ }
+ val fontWeight = when (layer.fontWeight.lowercase()) {
+ "bold" -> FontWeight.Bold
+ "semibold" -> FontWeight.SemiBold
+ "medium" -> FontWeight.Medium
+ "light" -> FontWeight.Light
+ "thin" -> FontWeight.Thin
+ "extrabold", "black" -> FontWeight.ExtraBold
+ else -> FontWeight.Normal
+ }
+ val fontStyle = when (layer.fontStyle.lowercase()) {
+ "italic" -> androidx.compose.ui.text.font.FontStyle.Italic
+ else -> androidx.compose.ui.text.font.FontStyle.Normal
+ }
+
+ // Build size modifier conditionally - only apply if dimensions are specified
+ val sizeModifier = when {
+ w != Dp.Unspecified && h != Dp.Unspecified -> Modifier.size(w, h)
+ w != Dp.Unspecified -> Modifier.width(w)
+ h != Dp.Unspecified -> Modifier.height(h)
+ else -> Modifier
+ }
+
+ Box(
+ modifier = Modifier
+ .zIndex(layer.zIndex)
+ .offset(x = pos.x, y = pos.y)
+ .then(sizeModifier)
+ .alpha(alpha),
+ contentAlignment = boxAlignment
+ ) {
+ Text(
+ text = text,
+ color = Color(color),
+ fontSize = textSizeSp,
+ fontWeight = fontWeight,
+ fontStyle = fontStyle,
+ maxLines = layer.maxLines ?: 1,
+ overflow = TextOverflow.Ellipsis,
+ textAlign = textAlignment,
+ style = shadow?.let { TextStyle(shadow = it) } ?: TextStyle.Default,
+ )
+ }
+ }
+
+ is Layer.BorderLayer -> {
+ val rawX = dimToDp(layer.position.x, parentSize.width, parentSize.height)
+ val rawY = dimToDp(layer.position.y, parentSize.width, parentSize.height)
+ val rawW = layer.size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val rawH = layer.size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+ val w = if (rawW == null || rawW == Dp.Unspecified) parentSize.width else rawW
+ val h = if (rawH == null || rawH == Dp.Unspecified) parentSize.height else rawH
+ val pos = calculateAnchoredPosition(rawX, rawY, w, h, parentSize.width, parentSize.height, layer.anchor)
+ val shape = parseCornerRadius(layer.cornerRadius)
+ val borderWidth = resolveFloatBinding(layer.strokeWidth, 1f).dp
+ val color = resolveIntBinding(layer.color, 0xFFFFFFFF.toInt(), bindings)
+ val alpha = resolveFloatBinding(layer.opacity, 1f)
+
+ Box(
+ modifier = Modifier
+ .zIndex(layer.zIndex)
+ .offset(x = pos.x, y = pos.y)
+ .size(w, h)
+ .alpha(alpha)
+ .border(width = borderWidth, color = Color(color), shape = shape)
+ )
+ }
+
+ is Layer.ShadowLayer -> {
+ // Shadows are complex in Compose - simplified implementation
+ val rawX = dimToDp(layer.position.x, parentSize.width, parentSize.height)
+ val rawY = dimToDp(layer.position.y, parentSize.width, parentSize.height)
+ val rawW = layer.size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val rawH = layer.size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+ val w = if (rawW == null || rawW == Dp.Unspecified) parentSize.width else rawW
+ val h = if (rawH == null || rawH == Dp.Unspecified) parentSize.height else rawH
+ // Note: anchor support added but shadow rendering is simplified/skipped
+ // val pos = calculateAnchoredPosition(rawX, rawY, w, h, parentSize.width, parentSize.height, layer.anchor)
+ }
+
+ is Layer.VideoLayer -> {
+ // Video not supported in grid tiles - show poster or placeholder
+ val rawX = dimToDp(layer.position.x, parentSize.width, parentSize.height)
+ val rawY = dimToDp(layer.position.y, parentSize.width, parentSize.height)
+ val rawW = layer.size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val rawH = layer.size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+ val w = if (rawW == null || rawW == Dp.Unspecified) parentSize.width else rawW
+ val h = if (rawH == null || rawH == Dp.Unspecified) parentSize.height else rawH
+ val pos = calculateAnchoredPosition(rawX, rawY, w, h, parentSize.width, parentSize.height, layer.anchor)
+
+ Box(
+ modifier = Modifier
+ .zIndex(layer.zIndex)
+ .offset(x = pos.x, y = pos.y)
+ .size(w, h)
+ .background(Color(0xFF303030)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("VIDEO", color = Color.White, fontSize = 10.sp)
+ }
+ }
+
+ is Layer.BackdropLayer -> {
+ // Backdrop blur effects - simplified
+ val rawX = dimToDp(layer.position.x, parentSize.width, parentSize.height)
+ val rawY = dimToDp(layer.position.y, parentSize.width, parentSize.height)
+ val rawW = layer.size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val rawH = layer.size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+ val w = if (rawW == null || rawW == Dp.Unspecified) parentSize.width else rawW
+ val h = if (rawH == null || rawH == Dp.Unspecified) parentSize.height else rawH
+ val pos = calculateAnchoredPosition(rawX, rawY, w, h, parentSize.width, parentSize.height, layer.anchor)
+ val tint = resolveIntBinding(layer.tintColor, 0, bindings)
+
+ if (tint != 0) {
+ Box(
+ modifier = Modifier
+ .zIndex(layer.zIndex)
+ .offset(x = pos.x, y = pos.y)
+ .size(w, h)
+ .background(Color(tint).copy(alpha = 0.5f))
+ )
+ }
+ }
+
+ is Layer.ButtonLayer -> {
+ val x = dimToDp(layer.position.x, parentSize.width, parentSize.height)
+ val y = dimToDp(layer.position.y, parentSize.width, parentSize.height)
+ // Handle Dp.Unspecified properly - use default if unspecified
+ val rawW = layer.size?.let { dimToDp(it.width, parentSize.width, parentSize.height) }
+ val rawH = layer.size?.let { dimToDp(it.height, parentSize.width, parentSize.height) }
+ val w = if (rawW == null || rawW == Dp.Unspecified) 80.dp else rawW
+ val h = if (rawH == null || rawH == Dp.Unspecified) 40.dp else rawH
+ val pos = calculateAnchoredPosition(x, y, w, h, parentSize.width, parentSize.height, layer.anchor)
+ val shape = parseCornerRadius(layer.cornerRadius)
+ // Use color resolver for system color support (@color/primary, etc.)
+ val bgColor = resolveColorBinding(layer.backgroundColor, MaterialTheme.colorScheme.primary, bindings)
+ val txtColor = resolveColorBinding(layer.textColor, MaterialTheme.colorScheme.onPrimary, bindings)
+ val rawText = resolveBinding(layer.text, bindings)
+ val text = stringResolver.resolve(rawText, themePath)
+ val textSizeSp = resolveFloatBinding(layer.textSize, 14f).sp
+ val alpha = resolveFloatBinding(layer.opacity, 1f)
+
+ // Parse padding: "vertical horizontal" or single value
+ val (paddingVertical, paddingHorizontal) = layer.padding?.let { paddingStr ->
+ val parts = paddingStr.trim().split("\\s+".toRegex())
+ when (parts.size) {
+ 1 -> {
+ val value = parts[0].toFloatOrNull() ?: 0f
+ Pair(value.dp, value.dp)
+ }
+ 2 -> {
+ val vertical = parts[0].toFloatOrNull() ?: 0f
+ val horizontal = parts[1].toFloatOrNull() ?: 0f
+ Pair(vertical.dp, horizontal.dp)
+ }
+ else -> Pair(0.dp, 0.dp)
+ }
+ } ?: Pair(0.dp, 0.dp)
+
+ Box(
+ modifier = Modifier
+ .zIndex(layer.zIndex)
+ .offset(x = pos.x, y = pos.y)
+ .size(w, h)
+ .alpha(alpha)
+ .clip(shape)
+ .background(bgColor)
+ .padding(horizontal = paddingHorizontal, vertical = paddingVertical),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = text,
+ color = txtColor,
+ fontSize = textSizeSp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+// region Binding Helpers
+
+private fun resolveBinding(value: StringOrBinding, bindings: Map): String {
+ return when (value) {
+ is StringOrBinding.Literal -> value.value
+ is StringOrBinding.Ref -> bindings[value.binding.path] ?: ""
+ }
+}
+
+private fun resolveFloatBinding(value: FloatOrBinding?, default: Float): Float {
+ return when (value) {
+ null -> default
+ is FloatOrBinding.Literal -> value.value
+ is FloatOrBinding.Ref -> default // Runtime bindings not supported yet
+ }
+}
+
+private fun resolveIntBinding(value: IntOrBinding?, default: Int, bindings: Map = emptyMap()): Int {
+ return when (value) {
+ null -> default
+ is IntOrBinding.Literal -> value.value
+ is IntOrBinding.Ref -> bindings[value.binding.path]?.let { parseColorString(it) } ?: default
+ }
+}
+
+/**
+ * Resolve a color binding with support for @color/ system references.
+ * Supported system colors:
+ * - @color/primary, @color/onPrimary
+ * - @color/secondary, @color/onSecondary
+ * - @color/tertiary, @color/onTertiary
+ * - @color/background, @color/onBackground
+ * - @color/surface, @color/onSurface
+ * - @color/error, @color/onError
+ * - @color/surfaceVariant, @color/onSurfaceVariant
+ */
+@Composable
+private fun resolveColorBinding(value: IntOrBinding?, default: Color, bindings: Map = emptyMap()): Color {
+ val colorScheme = MaterialTheme.colorScheme
+
+ return when (value) {
+ null -> default
+ is IntOrBinding.Literal -> {
+ // Check if the literal value encodes a system color reference
+ // This happens when the XML has @color/primary as the value
+ Color(value.value)
+ }
+ is IntOrBinding.Ref -> {
+ val path = value.binding.path
+ // Check for @color/ prefix (system colors)
+ if (path.startsWith("@color/")) {
+ val colorName = path.removePrefix("@color/")
+ resolveSystemColor(colorName, colorScheme) ?: default
+ } else {
+ // Regular binding from data
+ bindings[path]?.let { parseColorString(it) }?.let { Color(it) } ?: default
+ }
+ }
+ }
+}
+
+@Composable
+private fun resolveSystemColor(name: String, colorScheme: androidx.compose.material3.ColorScheme): Color? {
+ return when (name.lowercase()) {
+ "primary" -> colorScheme.primary
+ "onprimary" -> colorScheme.onPrimary
+ "primarycontainer" -> colorScheme.primaryContainer
+ "onprimarycontainer" -> colorScheme.onPrimaryContainer
+ "secondary" -> colorScheme.secondary
+ "onsecondary" -> colorScheme.onSecondary
+ "secondarycontainer" -> colorScheme.secondaryContainer
+ "onsecondarycontainer" -> colorScheme.onSecondaryContainer
+ "tertiary" -> colorScheme.tertiary
+ "ontertiary" -> colorScheme.onTertiary
+ "tertiarycontainer" -> colorScheme.tertiaryContainer
+ "ontertiarycontainer" -> colorScheme.onTertiaryContainer
+ "background" -> colorScheme.background
+ "onbackground" -> colorScheme.onBackground
+ "surface" -> colorScheme.surface
+ "onsurface" -> colorScheme.onSurface
+ "surfacevariant" -> colorScheme.surfaceVariant
+ "onsurfacevariant" -> colorScheme.onSurfaceVariant
+ "error" -> colorScheme.error
+ "onerror" -> colorScheme.onError
+ "errorcontainer" -> colorScheme.errorContainer
+ "onerrorcontainer" -> colorScheme.onErrorContainer
+ "outline" -> colorScheme.outline
+ "outlinevariant" -> colorScheme.outlineVariant
+ else -> null
+ }
+}
+
+private fun parseColorString(s: String): Int? {
+ return try {
+ android.graphics.Color.parseColor(s)
+ } catch (e: Exception) {
+ null
+ }
+}
+
+// Utility functions imported from ThemeUtils - see ThemeUtils.kt for implementations
+
+/** Alias for ThemeUtils.calculateAnchoredPosition with cleaner return type access */
+private fun calculateAnchoredPosition(
+ rawX: Dp,
+ rawY: Dp,
+ elementWidth: Dp,
+ elementHeight: Dp,
+ parentWidth: Dp,
+ parentHeight: Dp,
+ anchor: Anchor
+): ThemeUtils.Placement = ThemeUtils.calculateAnchoredPosition(rawX, rawY, elementWidth, elementHeight, parentWidth, parentHeight, anchor)
+
+// endregion
+
diff --git a/app/src/main/java/app/gamenative/theme/runtime/VariableResolver.kt b/app/src/main/java/app/gamenative/theme/runtime/VariableResolver.kt
new file mode 100644
index 000000000..a33a81369
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/VariableResolver.kt
@@ -0,0 +1,205 @@
+package app.gamenative.theme.runtime
+
+import app.gamenative.theme.model.Breakpoint
+
+/**
+ * Centralized variable resolution for the theme engine.
+ *
+ * Resolves variable references (e.g., @{vars.cornerRadius}) to their actual values,
+ * taking into account breakpoints for orientation-aware theming.
+ *
+ * This class is used at both:
+ * - Map-time: When converting ThemeTree to ThemeDefinition
+ * - Render-time: When resolving any remaining bindings
+ */
+object VariableResolver {
+
+ private const val VAR_PREFIX = "@{vars."
+ private const val BINDING_PREFIX = "@{"
+ private const val BINDING_SUFFIX = "}"
+
+ /**
+ * Resolve all matching breakpoints and merge their variables with base variables.
+ * Later breakpoints override earlier ones (CSS cascade behavior).
+ *
+ * @param baseVariables Default variable values from theme
+ * @param breakpoints List of breakpoints that may override variables
+ * @param isPortrait True if current orientation is portrait
+ * @param screenWidthDp Current screen width in dp
+ * @return Map of resolved variable names to values
+ */
+ fun resolveWithBreakpoints(
+ baseVariables: Map,
+ breakpoints: List,
+ isPortrait: Boolean,
+ screenWidthDp: Int
+ ): Map {
+ return buildMap {
+ // Start with base variables
+ putAll(baseVariables)
+
+ // Apply matching breakpoints in order (cascade)
+ breakpoints
+ .filter { it.matches(isPortrait, screenWidthDp) }
+ .forEach { breakpoint ->
+ putAll(breakpoint.variables)
+ }
+ }
+ }
+
+ /**
+ * Check if a string is a variable binding (e.g., @{vars.cornerRadius}).
+ */
+ fun isVariableBinding(value: String): Boolean {
+ return value.startsWith(VAR_PREFIX) && value.endsWith(BINDING_SUFFIX)
+ }
+
+ /**
+ * Check if a string is any kind of binding (e.g., @{vars.x} or @{game.title}).
+ */
+ fun isBinding(value: String): Boolean {
+ return value.startsWith(BINDING_PREFIX) && value.endsWith(BINDING_SUFFIX)
+ }
+
+ /**
+ * Extract the binding path from a binding string.
+ * E.g., "@{vars.cornerRadius}" -> "vars.cornerRadius"
+ */
+ fun getBindingPath(value: String): String {
+ return value.removePrefix(BINDING_PREFIX).removeSuffix(BINDING_SUFFIX)
+ }
+
+ /**
+ * Extract the variable name from a variable binding.
+ * E.g., "@{vars.cornerRadius}" -> "cornerRadius"
+ */
+ fun getVariableName(value: String): String? {
+ if (!isVariableBinding(value)) return null
+ val path = getBindingPath(value)
+ return path.removePrefix("vars.")
+ }
+
+ // Regex to find all @{vars.xxx} patterns
+ private val VAR_PATTERN = Regex("""@\{vars\.([^}]+)\}""")
+
+ /**
+ * Resolve a single value that may be a variable reference.
+ *
+ * @param value The value to resolve (may be literal or @{vars.name} reference)
+ * @param variables Map of resolved variables
+ * @return The resolved value, or null if variable not found
+ */
+ fun resolveValue(value: String?, variables: Map): String? {
+ if (value == null) return null
+
+ if (isVariableBinding(value)) {
+ val varName = getVariableName(value) ?: return null
+ return variables[varName]
+ }
+
+ return value
+ }
+
+ /**
+ * Resolve ALL variable references within a string.
+ *
+ * Unlike [resolveValue] which only works when the entire string is a single variable binding,
+ * this function finds and replaces all @{vars.xxx} patterns within the string.
+ *
+ * Example: "@{vars.radius} @{vars.radius} 0 0" -> "20 20 0 0"
+ *
+ * @param value The string potentially containing variable references
+ * @param variables Map of resolved variables
+ * @return The string with all variable references replaced, or null if input is null
+ */
+ fun resolveAllVariables(value: String?, variables: Map): String? {
+ if (value == null) return null
+
+ // If entire string is a single variable binding, use simple resolution
+ if (isVariableBinding(value)) {
+ return resolveValue(value, variables)
+ }
+
+ // Find and replace all @{vars.xxx} patterns
+ return VAR_PATTERN.replace(value) { match ->
+ val varName = match.groupValues[1]
+ variables[varName] ?: match.value // Keep original if not found
+ }
+ }
+
+ /**
+ * Resolve a value with a default fallback.
+ */
+ fun resolveValue(value: String?, variables: Map, default: String): String {
+ return resolveValue(value, variables) ?: default
+ }
+
+ /**
+ * Resolve a float value that may be a variable reference.
+ */
+ fun resolveFloat(value: String?, variables: Map, default: Float): Float {
+ val resolved = resolveValue(value, variables) ?: return default
+ return resolved.toFloatOrNull() ?: default
+ }
+
+ /**
+ * Resolve an optional float value.
+ */
+ fun resolveFloatOrNull(value: String?, variables: Map): Float? {
+ val resolved = resolveValue(value, variables) ?: return null
+ return resolved.toFloatOrNull()
+ }
+
+ /**
+ * Resolve an int value that may be a variable reference.
+ */
+ fun resolveInt(value: String?, variables: Map, default: Int): Int {
+ val resolved = resolveValue(value, variables) ?: return default
+ return resolved.toIntOrNull() ?: default
+ }
+
+ /**
+ * Resolve an optional int value.
+ */
+ fun resolveIntOrNull(value: String?, variables: Map): Int? {
+ val resolved = resolveValue(value, variables) ?: return null
+ return resolved.toIntOrNull()
+ }
+
+ /**
+ * Resolve a color value that may be a variable reference.
+ * Supports hex colors (#RRGGBB, #AARRGGBB, 0xRRGGBB, 0xAARRGGBB).
+ */
+ fun resolveColor(value: String?, variables: Map, default: Int): Int {
+ val resolved = resolveValue(value, variables) ?: return default
+ return parseColor(resolved) ?: default
+ }
+
+ /**
+ * Resolve an optional color value.
+ */
+ fun resolveColorOrNull(value: String?, variables: Map): Int? {
+ val resolved = resolveValue(value, variables) ?: return null
+ return parseColor(resolved)
+ }
+
+ /**
+ * Parse a color string to Int.
+ * Supports #RRGGBB, #AARRGGBB, 0xRRGGBB, 0xAARRGGBB formats.
+ */
+ private fun parseColor(value: String): Int? {
+ var v = value.trim()
+ val isHex = v.startsWith("#") || v.lowercase().startsWith("0x")
+ if (!isHex) return v.toLongOrNull()?.toInt()
+
+ v = v.removePrefix("#").removePrefix("0x").removePrefix("0X")
+ val parsed = v.toLongOrNull(16) ?: return null
+ return if (v.length <= 6) {
+ // RRGGBB -> assume opaque
+ (0xFF000000 or parsed).toInt()
+ } else {
+ parsed.toInt() // AARRGGBB
+ }
+ }
+}
+
diff --git a/app/src/main/java/app/gamenative/theme/runtime/layers/LayerRenderers.kt b/app/src/main/java/app/gamenative/theme/runtime/layers/LayerRenderers.kt
new file mode 100644
index 000000000..97b27cd0b
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/runtime/layers/LayerRenderers.kt
@@ -0,0 +1,442 @@
+package app.gamenative.theme.runtime.layers
+
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.BlurredEdgeTreatment
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.AspectRatioFrameLayout
+import androidx.media3.ui.PlayerView
+import app.gamenative.theme.model.*
+import app.gamenative.theme.runtime.BindingContext
+import app.gamenative.theme.runtime.SharedElementRenderers
+import app.gamenative.theme.runtime.ThemeUtils
+import app.gamenative.theme.runtime.parseCornerRadius
+
+/** Dispatcher for rendering a single template layer. */
+@Composable
+fun BoxScope.RenderLayer(layer: Layer, parentSize: DpSize, binding: BindingContext, anchor: Anchor = Anchor.TOP_LEFT) {
+ when (layer) {
+ is Layer.ImageLayer -> ImageLayerView(layer, parentSize, binding, anchor)
+ is Layer.VideoLayer -> VideoLayerView(layer, parentSize, binding, anchor)
+ is Layer.RectLayer -> RectLayerView(layer, parentSize, binding, anchor)
+ is Layer.ShadowLayer -> ShadowLayerView(layer, parentSize, binding, anchor)
+ is Layer.BorderLayer -> BorderLayerView(layer, parentSize, binding, anchor)
+ is Layer.TextLayer -> TextLayerView(layer, parentSize, binding, anchor)
+ is Layer.BackdropLayer -> BackdropLayerView(layer, parentSize, binding, anchor)
+ is Layer.ButtonLayer -> ButtonLayerView(layer, parentSize, binding, anchor)
+ }
+}
+
+// --- Helpers ---
+
+/** Alias for ThemeUtils.calculatePlacement for cleaner code */
+private fun place(parent: DpSize, pos: DimOffset, size: DimSize?, defaultSize: DpSize, anchor: Anchor): ThemeUtils.Placement =
+ ThemeUtils.calculatePlacement(parent, pos, size, defaultSize, anchor)
+
+@Composable
+private fun BindingContext.or(value: FloatOrBinding?, default: Float): Float = when (value) {
+ null -> default
+ is FloatOrBinding.Literal -> value.value
+ is FloatOrBinding.Ref -> resolveFloat(value) ?: default
+}
+
+@Composable
+private fun BindingContext.or(value: IntOrBinding?, default: Int): Int = when (value) {
+ null -> default
+ is IntOrBinding.Literal -> value.value
+ is IntOrBinding.Ref -> resolveInt(value) ?: default
+}
+
+@Composable
+private fun BindingContext.or(value: StringOrBinding?, default: String): String = when (value) {
+ null -> default
+ is StringOrBinding.Literal -> value.value
+ is StringOrBinding.Ref -> resolveString(value) ?: default
+}
+
+// --- Layer views ---
+
+@Composable
+private fun BoxScope.ImageLayerView(layer: Layer.ImageLayer, parentSize: DpSize, binding: BindingContext, anchor: Anchor) {
+ val p = place(parentSize, layer.position, layer.size, defaultSize = DpSize(parentSize.width, parentSize.height), layer.anchor)
+ val alpha = binding.or(layer.opacity, 1f)
+ val shape = parseCornerRadius(layer.cornerRadius)
+ val tintInt = binding.or(layer.tintColor, 0)
+ val tint = if (tintInt != 0) Color(tintInt) else null
+
+ // Resolve the image source using the media pipeline, mapping bindings like "game.capsule".
+ val mediaManager = remember { app.gamenative.theme.media.MediaSourceManager() }
+ val resolved = mediaManager.resolve(
+ media = layer.source,
+ allowVideo = false,
+ bindingResolver = { b ->
+ // Bridge our simple BindingContext to theme Binding resolver
+ binding.resolveString(app.gamenative.theme.model.StringOrBinding.Ref(b))
+ },
+ themeRoot = null,
+ ) as app.gamenative.theme.media.ResolvedMedia.Image
+
+ // When no image could be resolved, show a tinted placeholder to match previous behavior.
+ if (resolved.uri.isNullOrEmpty()) {
+ Box(
+ modifier = Modifier
+ .offset(p.x, p.y)
+ .size(p.width, p.height)
+ .clip(shape)
+ .graphicsLayer(alpha = alpha)
+ .background(tint ?: Color(0xFF555555))
+ ) {}
+ return
+ }
+
+ // Determine content scale based on scaleType attribute
+ val contentScale = when (layer.scaleType.lowercase()) {
+ "contain", "fit" -> androidx.compose.ui.layout.ContentScale.Fit
+ "stretch", "fill" -> androidx.compose.ui.layout.ContentScale.FillBounds
+ else -> androidx.compose.ui.layout.ContentScale.Crop // "cover" is default
+ }
+
+ // Render the resolved image with Coil (Landscapist), keeping clipping, alpha and optional tint overlay.
+ Box(
+ modifier = Modifier
+ .offset(p.x, p.y)
+ .size(p.width, p.height)
+ .clip(shape)
+ .graphicsLayer(alpha = alpha)
+ ) {
+ com.skydoves.landscapist.coil.CoilImage(
+ modifier = Modifier.fillMaxSize(),
+ imageModel = { resolved.uri },
+ imageOptions = com.skydoves.landscapist.ImageOptions(
+ contentScale = contentScale,
+ contentDescription = null,
+ ),
+ )
+ if (tint != null) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(tint)
+ ) {}
+ }
+ }
+}
+
+@OptIn(UnstableApi::class)
+@Composable
+private fun BoxScope.VideoLayerView(layer: Layer.VideoLayer, parentSize: DpSize, binding: BindingContext, anchor: Anchor) {
+ val p = place(parentSize, layer.position, layer.size, defaultSize = DpSize(parentSize.width, parentSize.height), layer.anchor)
+ val alpha = binding.or(layer.opacity, 1f)
+ val shape = parseCornerRadius(layer.cornerRadius)
+ val context = LocalContext.current
+
+ // Resolve video source from binding
+ val mediaManager = remember { app.gamenative.theme.media.MediaSourceManager() }
+ val videoSrc = binding.or(layer.source.src, "")
+ val posterSrc = layer.source.poster?.let { binding.or(it, "") }
+
+ // If no video source, show placeholder with poster if available
+ if (videoSrc.isEmpty()) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .offset(p.x, p.y)
+ .size(p.width, p.height)
+ .clip(shape)
+ .graphicsLayer(alpha = alpha)
+ .background(Color(0xFF303030))
+ ) {
+ if (!posterSrc.isNullOrEmpty()) {
+ com.skydoves.landscapist.coil.CoilImage(
+ modifier = Modifier.fillMaxSize(),
+ imageModel = { posterSrc },
+ imageOptions = com.skydoves.landscapist.ImageOptions(
+ contentScale = androidx.compose.ui.layout.ContentScale.Crop,
+ contentDescription = "Video poster",
+ ),
+ )
+ }
+ Text("▶", color = Color.White, fontSize = 24.sp)
+ }
+ return
+ }
+
+ // Create and remember ExoPlayer instance
+ val exoPlayer = remember(videoSrc) {
+ ExoPlayer.Builder(context).build().apply {
+ val mediaItem = MediaItem.fromUri(videoSrc)
+ setMediaItem(mediaItem)
+ repeatMode = if (layer.source.loop) Player.REPEAT_MODE_ALL else Player.REPEAT_MODE_OFF
+ volume = if (layer.source.muted) 0f else 1f
+ playWhenReady = layer.source.autoplay
+ prepare()
+ }
+ }
+
+ // Clean up player when composable leaves composition
+ DisposableEffect(exoPlayer) {
+ onDispose {
+ exoPlayer.release()
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .offset(p.x, p.y)
+ .size(p.width, p.height)
+ .clip(shape)
+ .graphicsLayer(alpha = alpha)
+ ) {
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ player = exoPlayer
+ useController = false // Hide playback controls
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
+ layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+}
+
+@Composable
+private fun BoxScope.RectLayerView(layer: Layer.RectLayer, parentSize: DpSize, binding: BindingContext, anchor: Anchor) {
+ val p = place(parentSize, layer.position, layer.size, defaultSize = DpSize(parentSize.width, parentSize.height), layer.anchor)
+ val alpha = binding.or(layer.opacity, 1f)
+ val fillColor = Color(binding.or(layer.color, 0x66000000.toInt()))
+ val borderWidth = binding.or(layer.borderWidth, 0f)
+ val borderColor = Color(binding.or(layer.borderColor, 0xFFFFFFFF.toInt()))
+ val gradientStartInt = binding.or(layer.gradientStart, 0)
+ val gradientEndInt = binding.or(layer.gradientEnd, 0)
+ val gradientAngle = binding.or(layer.gradientAngle, 0f)
+
+ SharedElementRenderers.RenderRect(
+ modifier = Modifier.offset(p.x, p.y),
+ width = p.width,
+ height = p.height,
+ color = fillColor,
+ cornerRadius = layer.cornerRadius,
+ borderWidth = borderWidth,
+ borderColor = borderColor,
+ borderGradient = layer.borderGradient,
+ gradientStart = if (gradientStartInt != 0) Color(gradientStartInt) else null,
+ gradientEnd = if (gradientEndInt != 0) Color(gradientEndInt) else null,
+ gradientAngle = gradientAngle,
+ opacity = alpha,
+ )
+}
+
+@Composable
+private fun BoxScope.ShadowLayerView(layer: Layer.ShadowLayer, parentSize: DpSize, binding: BindingContext, anchor: Anchor) {
+ val p = place(parentSize, layer.position, layer.size, defaultSize = DpSize(parentSize.width, parentSize.height), layer.anchor)
+ val alpha = binding.or(layer.opacity, 1f)
+ val radius = binding.or(layer.radius, 0f)
+ val color = Color(binding.or(layer.color, 0x88000000.toInt()))
+ val offsetX = ThemeUtils.dimToDp(layer.offset.x, parentSize.width, parentSize.height)
+ val offsetY = ThemeUtils.dimToDp(layer.offset.y, parentSize.width, parentSize.height)
+
+ SharedElementRenderers.RenderShadow(
+ modifier = Modifier.offset(p.x + offsetX, p.y + offsetY),
+ width = p.width,
+ height = p.height,
+ radius = radius,
+ color = color,
+ offsetX = 0f, // Already applied in modifier
+ offsetY = 0f,
+ cornerRadius = layer.cornerRadius,
+ opacity = alpha,
+ )
+}
+
+@Composable
+private fun BoxScope.BorderLayerView(layer: Layer.BorderLayer, parentSize: DpSize, binding: BindingContext, anchor: Anchor) {
+ val p = place(parentSize, layer.position, layer.size, defaultSize = DpSize(parentSize.width, parentSize.height), layer.anchor)
+ val alpha = binding.or(layer.opacity, 1f)
+ val strokeWidth = binding.or(layer.strokeWidth, 1f)
+ val color = Color(binding.or(layer.color, 0xFFFFFFFF.toInt()))
+
+ SharedElementRenderers.RenderBorder(
+ modifier = Modifier.offset(p.x, p.y),
+ width = p.width,
+ height = p.height,
+ strokeWidth = strokeWidth,
+ color = color,
+ cornerRadius = layer.cornerRadius,
+ opacity = alpha,
+ )
+}
+
+@Composable
+private fun BoxScope.TextLayerView(layer: Layer.TextLayer, parentSize: DpSize, binding: BindingContext, anchor: Anchor) {
+ // Use 0.dp as default size for text without explicit dimensions
+ // This ensures anchor calculations work correctly (text grows from anchor point)
+ val p = place(parentSize, layer.position, layer.size, defaultSize = DpSize(0.dp, 0.dp), layer.anchor)
+ val alpha = binding.or(layer.opacity, 1f)
+ val color = Color(binding.or(layer.color, 0xFFFFFFFF.toInt()))
+ val textSize = binding.or(layer.textSize, 16f)
+ val text = when (val t = layer.text) {
+ is StringOrBinding.Literal -> t.value
+ is StringOrBinding.Ref -> binding.resolveString(t) ?: ""
+ }
+ val lineHeight = binding.or(layer.lineHeight, 0f).let { if (it > 0f) it else null }
+ val letterSpacing = binding.or(layer.letterSpacing, 0f).let { if (it != 0f) it else null }
+
+ // Shadow properties
+ val shadowColorInt = binding.or(layer.shadowColor, 0)
+ val shadowColor = if (shadowColorInt != 0) Color(shadowColorInt) else null
+ val shadowRadius = binding.or(layer.shadowRadius, 0f)
+ val shadowOffsetX = binding.or(layer.shadowOffsetX, 0f)
+ val shadowOffsetY = binding.or(layer.shadowOffsetY, 0f)
+
+ // Convert Dp.Unspecified to null for RenderText
+ val width = if (p.width == Dp.Unspecified) null else p.width
+ val height = if (p.height == Dp.Unspecified) null else p.height
+
+ SharedElementRenderers.RenderText(
+ modifier = Modifier.offset(p.x, p.y),
+ width = width,
+ height = height,
+ text = text,
+ color = color,
+ textSize = textSize,
+ maxLines = layer.maxLines,
+ textAlign = layer.textAlign,
+ fontWeight = layer.fontWeight,
+ fontStyle = layer.fontStyle,
+ lineHeight = lineHeight,
+ letterSpacing = letterSpacing,
+ textDecoration = layer.textDecoration,
+ overflow = layer.overflow,
+ opacity = alpha,
+ shadowColor = shadowColor,
+ shadowRadius = shadowRadius,
+ shadowOffsetX = shadowOffsetX,
+ shadowOffsetY = shadowOffsetY,
+ )
+}
+
+@Composable
+private fun BoxScope.BackdropLayerView(layer: Layer.BackdropLayer, parentSize: DpSize, binding: BindingContext, anchor: Anchor) {
+ val p = place(parentSize, layer.position, layer.size, defaultSize = DpSize(parentSize.width, parentSize.height), layer.anchor)
+ val alpha = binding.or(layer.opacity, 1f)
+ val blurRadius = binding.or(layer.blurRadius, 0f)
+ val tintInt = binding.or(layer.tintColor, 0)
+ val tintColor = if (tintInt != 0) Color(tintInt) else null
+
+ SharedElementRenderers.RenderBackdrop(
+ modifier = Modifier.offset(p.x, p.y),
+ width = p.width,
+ height = p.height,
+ blurRadius = blurRadius,
+ tintColor = tintColor,
+ cornerRadius = null, // BackdropLayer doesn't have cornerRadius in the model
+ opacity = alpha,
+ )
+}
+
+@Composable
+private fun BoxScope.ButtonLayerView(layer: Layer.ButtonLayer, parentSize: DpSize, binding: BindingContext, anchor: Anchor) {
+ val p = place(parentSize, layer.position, layer.size, defaultSize = DpSize(80.dp, 40.dp), layer.anchor)
+ val alpha = binding.or(layer.opacity, 1f)
+ val shape = parseCornerRadius(layer.cornerRadius)
+ val bgColorInt = binding.or(layer.backgroundColor, 0xFFE91E63.toInt())
+ val textColorInt = binding.or(layer.textColor, 0xFFFFFFFF.toInt())
+ val textSizeSp = binding.or(layer.textSize, 14f).sp
+ val text = when (val t = layer.text) {
+ is StringOrBinding.Literal -> t.value
+ is StringOrBinding.Ref -> binding.resolveString(t) ?: ""
+ }
+
+ val borderWidth = binding.or(layer.borderWidth, 0f)
+ val borderColorInt = binding.or(layer.borderColor, 0xFFFFFFFF.toInt())
+ val fontWeight = SharedElementRenderers.parseFontWeight(layer.fontWeight)
+
+ // Parse padding: "vertical horizontal" or single value
+ val (paddingVertical, paddingHorizontal) = layer.padding?.let { paddingStr ->
+ val parts = paddingStr.trim().split("\\s+".toRegex())
+ when (parts.size) {
+ 1 -> {
+ val value = parts[0].toFloatOrNull() ?: 0f
+ Pair(value.dp, value.dp)
+ }
+ 2 -> {
+ val vertical = parts[0].toFloatOrNull() ?: 0f
+ val horizontal = parts[1].toFloatOrNull() ?: 0f
+ Pair(vertical.dp, horizontal.dp)
+ }
+ else -> Pair(0.dp, 0.dp)
+ }
+ } ?: Pair(0.dp, 0.dp)
+
+ // Calculate size modifier - if no explicit size, use wrapContent with padding
+ val sizeModifier = if (p.width == Dp.Unspecified || p.height == Dp.Unspecified) {
+ Modifier
+ } else {
+ Modifier.size(p.width, p.height)
+ }
+
+ Box(
+ modifier = Modifier
+ .offset(p.x, p.y)
+ .then(sizeModifier)
+ .graphicsLayer(alpha = alpha)
+ .clip(shape)
+ .background(Color(bgColorInt))
+ .then(
+ if (borderWidth > 0f) {
+ Modifier.border(borderWidth.dp, Color(borderColorInt), shape)
+ } else {
+ Modifier
+ }
+ )
+ .padding(horizontal = paddingHorizontal, vertical = paddingVertical),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = text,
+ color = Color(textColorInt),
+ fontSize = textSizeSp,
+ fontWeight = fontWeight,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+}
diff --git a/app/src/main/java/app/gamenative/theme/validate/ThemeValidator.kt b/app/src/main/java/app/gamenative/theme/validate/ThemeValidator.kt
new file mode 100644
index 000000000..e0a81d0c2
--- /dev/null
+++ b/app/src/main/java/app/gamenative/theme/validate/ThemeValidator.kt
@@ -0,0 +1,502 @@
+package app.gamenative.theme.validate
+
+import app.gamenative.theme.model.SourceLoc
+import app.gamenative.theme.model.ThemeEngine
+import app.gamenative.theme.model.ThemeTree
+import app.gamenative.theme.model.XmlNode
+import org.xml.sax.Attributes
+import org.xml.sax.helpers.DefaultHandler
+import java.io.File
+import java.nio.file.Path
+import java.util.Locale
+import javax.xml.parsers.SAXParserFactory
+
+/** Severity for validation issues. */
+enum class Severity { INFO, WARNING, ERROR }
+
+/** Stable validation codes for programmatic handling and tests. */
+enum class ValidationCode {
+ // Compatibility
+ ENGINE_VERSION_MISMATCH,
+ APP_VERSION_OUT_OF_RANGE,
+ MANIFEST_MISSING,
+ REQUIRED_FIELD_MISSING,
+
+ // Schema & references
+ DUPLICATE_ID,
+ BAD_TEMPLATE_REF,
+ UNKNOWN_STATE_REF,
+ UNKNOWN_LAYER_REF,
+ INVALID_RANGE,
+ INVALID_VALUE,
+
+ // Media/assets
+ MISSING_MEDIA_SRC,
+ ASSET_NOT_FOUND,
+ POSTER_MISSING,
+}
+
+/** A single validation finding. */
+data class ValidationIssue(
+ val code: ValidationCode,
+ val severity: Severity,
+ val message: String,
+ val source: SourceLoc? = null,
+)
+
+/** Aggregate validation result. */
+data class ValidationResult(val issues: List) {
+ fun hasBlocking(): Boolean = issues.any { it.severity == Severity.ERROR }
+}
+
+/**
+ * Validates a merged ThemeTree (from ThemeLoader) for compatibility and schema basics.
+ * Never throws; always returns a list of diagnostics.
+ */
+object ThemeValidator {
+
+ /** Validate and return issues. Incompatible themes should be blocked before selection. */
+ fun validate(tree: ThemeTree, appVersion: String, engineVersion: String = ThemeEngine.ENGINE_VERSION): ValidationResult {
+ val issues = mutableListOf()
+
+ // 1) Compatibility gates via manifest.xml
+ validateManifest(tree, appVersion, engineVersion, issues)
+
+ // 2) Basic schema / referential integrity on theme.xml
+ validateThemeXml(tree, issues)
+
+ return ValidationResult(issues)
+ }
+
+ // region Manifest
+ private fun validateManifest(tree: ThemeTree, appVersion: String, engineVersion: String, out: MutableList) {
+ val manifestFile = File(tree.rootDir, "manifest.xml")
+ if (!manifestFile.exists()) {
+ out += ValidationIssue(
+ ValidationCode.MANIFEST_MISSING, Severity.ERROR,
+ "Theme manifest.xml missing; cannot determine compatibility.",
+ SourceLoc(manifestFile.absolutePath)
+ )
+ return
+ }
+ var engineConstraint: String? = null
+ var minApp: String? = null
+ var maxApp: String? = null
+ var src: SourceLoc? = null
+
+ try {
+ val factory = SAXParserFactory.newInstance()
+ val parser = factory.newSAXParser()
+ val handler = object : DefaultHandler() {
+ private var locator: org.xml.sax.Locator? = null
+ private var currentElement: String? = null
+ private val textBuffer = StringBuilder()
+
+ override fun setDocumentLocator(locator: org.xml.sax.Locator?) { this.locator = locator }
+
+ override fun startElement(uri: String?, localName: String?, qName: String, attributes: Attributes) {
+ currentElement = qName.lowercase()
+ textBuffer.clear()
+ if (qName.equals("manifest", ignoreCase = true)) {
+ src = SourceLoc(manifestFile.absolutePath, locator?.lineNumber, locator?.columnNumber)
+ // Support both attribute and child element formats
+ attributes.getValue("engineVersion")?.let { v ->
+ engineConstraint = v.trim()
+ }
+ attributes.getValue("minAppVersion")?.let { minApp = it }
+ attributes.getValue("maxAppVersion")?.let { maxApp = it }
+ }
+ }
+
+ override fun characters(ch: CharArray, start: Int, length: Int) {
+ textBuffer.append(ch, start, length)
+ }
+
+ override fun endElement(uri: String?, localName: String?, qName: String) {
+ val text = textBuffer.toString().trim()
+ when (qName.lowercase()) {
+ "engineversion" -> if (text.isNotEmpty()) engineConstraint = text
+ "minappversion" -> if (text.isNotEmpty()) minApp = text
+ "maxappversion" -> if (text.isNotEmpty()) maxApp = text
+ }
+ currentElement = null
+ textBuffer.clear()
+ }
+ }
+ parser.parse(manifestFile, handler)
+ } catch (e: Exception) {
+ out += ValidationIssue(
+ ValidationCode.REQUIRED_FIELD_MISSING, Severity.ERROR,
+ "Failed to parse manifest.xml: ${e.message}",
+ SourceLoc(manifestFile.absolutePath)
+ )
+ return
+ }
+
+ if (engineConstraint == null) {
+ out += ValidationIssue(
+ ValidationCode.REQUIRED_FIELD_MISSING, Severity.ERROR,
+ "Manifest must declare engineVersion.", src
+ )
+ } else if (!ThemeEngine.matchesConstraint(engineConstraint!!, engineVersion)) {
+ out += ValidationIssue(
+ ValidationCode.ENGINE_VERSION_MISMATCH, Severity.ERROR,
+ "Theme engineVersion constraint '$engineConstraint' does not match app engine version '$engineVersion'.", src
+ )
+ }
+
+ if (minApp == null) {
+ out += ValidationIssue(
+ ValidationCode.REQUIRED_FIELD_MISSING, Severity.ERROR,
+ "Manifest must declare minAppVersion.", src
+ )
+ } else {
+ val cmpMin = compareSemVer(appVersion, minApp!!)
+ if (cmpMin < 0) {
+ out += ValidationIssue(
+ ValidationCode.APP_VERSION_OUT_OF_RANGE, Severity.ERROR,
+ "App version $appVersion is older than required minAppVersion $minApp.", src
+ )
+ }
+ }
+ maxApp?.let { max ->
+ val cmpMax = compareSemVer(appVersion, max)
+ if (cmpMax > 0) {
+ out += ValidationIssue(
+ ValidationCode.APP_VERSION_OUT_OF_RANGE, Severity.ERROR,
+ "App version $appVersion exceeds maxAppVersion $max.", src
+ )
+ }
+ }
+ }
+ // endregion
+
+ // region Theme XML
+ private fun validateThemeXml(tree: ThemeTree, out: MutableList) {
+ val root = tree.themeXml
+ // Collect card/template IDs (support both new "card" and legacy "template" naming)
+ val cardIds = LinkedHashSet()
+ val cardNodes = mutableListOf()
+ // Track grids/carousels with inline cards
+ val layoutNodesWithInlineCards = mutableSetOf()
+
+ traverseWithParent(root) { node, parent ->
+ // Support both (new) and (legacy)
+ if (node.name.equals("card", ignoreCase = true) || node.name.equals("template", ignoreCase = true)) {
+ cardNodes += node
+ val id = node.attributes["id"]?.trim().orEmpty()
+
+ // Check if this is an inline card (direct child of grid/carousel)
+ val isInlineCard = parent != null &&
+ (parent.name.equals("grid", ignoreCase = true) || parent.name.equals("carousel", ignoreCase = true))
+
+ if (isInlineCard) {
+ // Inline cards don't require an explicit id (one will be auto-generated)
+ // Track this grid/carousel as having an inline card
+ layoutNodesWithInlineCards.add(parent)
+ if (id.isNotEmpty()) {
+ // If id is provided, add it to the set for reference validation
+ if (!cardIds.add(id)) {
+ out += ValidationIssue(
+ ValidationCode.DUPLICATE_ID, Severity.ERROR,
+ "Duplicate card id '$id'.", node.source
+ )
+ }
+ }
+ } else {
+ // Non-inline cards must have an id
+ if (id.isEmpty()) {
+ out += ValidationIssue(
+ ValidationCode.REQUIRED_FIELD_MISSING, Severity.ERROR,
+ "Card must declare non-empty id.", node.source
+ )
+ } else if (!cardIds.add(id)) {
+ out += ValidationIssue(
+ ValidationCode.DUPLICATE_ID, Severity.ERROR,
+ "Duplicate card id '$id'.", node.source
+ )
+ }
+ }
+ validateLayers(node, out)
+ validateStatesTransitions(node, out)
+ }
+ if (node.name.equals("grid", ignoreCase = true)) {
+ // Columns is optional (null = adaptive), but if specified must be > 0
+ node.attributes["columns"]?.toIntOrNull()?.let { columns ->
+ if (columns <= 0) {
+ out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Grid columns must be > 0 when specified.", node.source)
+ }
+ }
+ node.attributes["rows"]?.toIntOrNull()?.let { if (it <= 0) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Grid rows must be > 0 when specified.", node.source) }
+ // cellWidth is optional (defaults to 100%), but if specified must be > 0
+ val cellW = parseDimensionValue(node.attributes["cellWidth"])
+ if (cellW != null && cellW <= 0f) {
+ out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Grid cellWidth must be > 0 when specified.", node.source)
+ }
+ // cellHeight is optional - if specified, must be > 0
+ val cellH = parseDimensionValue(node.attributes["cellHeight"])
+ if (cellH != null && cellH <= 0f) {
+ out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Grid cellHeight must be > 0 when specified.", node.source)
+ }
+ // aspectRatio is optional - if specified, must be > 0
+ node.attributes["aspectRatio"]?.toFloatOrNull()?.let { ratio ->
+ if (ratio <= 0f) {
+ out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Grid aspectRatio must be > 0 when specified.", node.source)
+ }
+ }
+ }
+ if (node.name.equals("carousel", ignoreCase = true)) {
+ // itemWidth/itemHeight are optional (default to 200px), but if specified must be > 0
+ val itemW = parseDimensionValue(node.attributes["itemWidth"])
+ val itemH = parseDimensionValue(node.attributes["itemHeight"])
+ if (itemW != null && itemW <= 0f) {
+ out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Carousel itemWidth must be > 0 when specified.", node.source)
+ }
+ if (itemH != null && itemH <= 0f) {
+ out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Carousel itemHeight must be > 0 when specified.", node.source)
+ }
+ node.attributes["pageSize"]?.toIntOrNull()?.let { if (it <= 0) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Carousel pageSize must be > 0 when specified.", node.source) }
+ }
+ if (node.name.equals("image", ignoreCase = true)) {
+ validateImageNode(tree, node, out)
+ }
+ if (node.name.equals("video", ignoreCase = true)) {
+ validateVideoNode(tree, node, out)
+ }
+ if (node.name.equals("canvas", ignoreCase = true)) {
+ val w = parseDimensionValue(node.attributes["width"])
+ val h = parseDimensionValue(node.attributes["height"])
+ if (w == null || w <= 0f || h == null || h <= 0f) {
+ out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Canvas width/height must be > 0.", node.source)
+ }
+ }
+ if (node.name.equals("child", ignoreCase = true)) {
+ val ref = node.attributes["template"]
+ if (ref.isNullOrBlank()) {
+ out += ValidationIssue(ValidationCode.REQUIRED_FIELD_MISSING, Severity.ERROR, "Canvas child requires template attribute.", node.source)
+ }
+ }
+ }
+
+ // After traversal, validate card/template references
+ traverse(root) { node ->
+ when (node.name.lowercase(Locale.ROOT)) {
+ "grid" -> {
+ // Skip validation if this grid has an inline card
+ if (layoutNodesWithInlineCards.contains(node)) return@traverse
+
+ // Support both "itemCard" (new) and "itemTemplate" (legacy)
+ val ref = node.attributes["itemCard"] ?: node.attributes["itemTemplate"]
+ if (ref.isNullOrBlank() || !cardIds.contains(ref)) {
+ out += ValidationIssue(ValidationCode.BAD_TEMPLATE_REF, Severity.ERROR, "Grid itemCard '$ref' not found.", node.source)
+ }
+ }
+ "carousel" -> {
+ // Skip validation if this carousel has an inline card
+ if (layoutNodesWithInlineCards.contains(node)) return@traverse
+
+ // Support both "itemCard" (new) and "itemTemplate" (legacy)
+ val ref = node.attributes["itemCard"] ?: node.attributes["itemTemplate"]
+ if (ref.isNullOrBlank() || !cardIds.contains(ref)) {
+ out += ValidationIssue(ValidationCode.BAD_TEMPLATE_REF, Severity.ERROR, "Carousel itemCard '$ref' not found.", node.source)
+ }
+ }
+ "child" -> {
+ // Support both "card" (new) and "template" (legacy)
+ val ref = node.attributes["card"] ?: node.attributes["template"]
+ if (ref.isNullOrBlank() || !cardIds.contains(ref)) {
+ out += ValidationIssue(ValidationCode.BAD_TEMPLATE_REF, Severity.ERROR, "Canvas child card '$ref' not found.", node.source)
+ }
+ }
+ }
+ }
+ }
+
+ private fun validateLayers(cardNode: XmlNode, out: MutableList) {
+ // Ensure unique layer ids within a card
+ val layerIds = HashSet()
+ cardNode.children.forEach { child ->
+ when (child.name.lowercase(Locale.ROOT)) {
+ "image", "video", "overlay", "shadow", "border", "text", "backdrop" -> {
+ val id = child.attributes["id"]?.trim()
+ if (!id.isNullOrEmpty()) {
+ if (!layerIds.add(id)) {
+ out += ValidationIssue(ValidationCode.DUPLICATE_ID, Severity.ERROR, "Duplicate layer id '$id' in card '${cardNode.attributes["id"]}'.", child.source)
+ }
+ }
+ // Common checks
+ child.attributes["opacity"]?.toFloatOrNull()?.let { if (it < 0f || it > 1f) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Layer opacity must be in [0,1].", child.source) }
+ }
+ }
+ }
+ }
+
+ private fun validateStatesTransitions(cardNode: XmlNode, out: MutableList) {
+ // Find states declared under this card
+ val stateNames = HashSet()
+ val statesParent = cardNode.children.firstOrNull { it.name.equals("states", true) }
+ statesParent?.children?.forEach { st ->
+ if (st.name.equals("state", true)) {
+ val name = st.attributes["name"]?.trim().orEmpty()
+ if (name.isEmpty()) {
+ out += ValidationIssue(ValidationCode.REQUIRED_FIELD_MISSING, Severity.ERROR, "State requires name.", st.source)
+ } else if (!stateNames.add(name)) {
+ out += ValidationIssue(ValidationCode.DUPLICATE_ID, Severity.ERROR, "Duplicate state '$name' in card '${cardNode.attributes["id"]}'.", st.source)
+ }
+ // Modifiers sanity
+ st.children.forEach { mod ->
+ when (mod.name.lowercase(Locale.ROOT)) {
+ "opacity" -> mod.attributes["value"]?.toFloatOrNull()?.let { if (it < 0f || it > 1f) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Opacity must be in [0,1] in state '$name'.", mod.source) }
+ "shadow" -> mod.attributes["radiusPx"]?.toFloatOrNull()?.let { if (it < 0f) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Shadow radius must be >= 0.", mod.source) }
+ "border" -> mod.attributes["widthPx"]?.toFloatOrNull()?.let { if (it < 0f) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Border width must be >= 0.", mod.source) }
+ "blur" -> mod.attributes["radiusPx"]?.toFloatOrNull()?.let { if (it < 0f) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "Blur radius must be >= 0.", mod.source) }
+ }
+ }
+ }
+ }
+ val transitionsParent = cardNode.children.firstOrNull { it.name.equals("transitions", true) }
+ transitionsParent?.children?.forEach { tr ->
+ if (tr.name.equals("transition", true)) {
+ val from = tr.attributes["from"]
+ val to = tr.attributes["to"]
+ if (from.isNullOrBlank() || to.isNullOrBlank()) {
+ out += ValidationIssue(ValidationCode.REQUIRED_FIELD_MISSING, Severity.ERROR, "Transition requires 'from' and 'to'.", tr.source)
+ } else {
+ if (!stateNames.contains(from)) out += ValidationIssue(ValidationCode.UNKNOWN_STATE_REF, Severity.ERROR, "Transition from '$from' not declared.", tr.source)
+ if (!stateNames.contains(to)) out += ValidationIssue(ValidationCode.UNKNOWN_STATE_REF, Severity.ERROR, "Transition to '$to' not declared.", tr.source)
+ }
+ tr.attributes["durationMs"]?.toIntOrNull()?.let { if (it < 0) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "durationMs must be >= 0.", tr.source) }
+ tr.attributes["delayMs"]?.toIntOrNull()?.let { if (it < 0) out += ValidationIssue(ValidationCode.INVALID_RANGE, Severity.ERROR, "delayMs must be >= 0.", tr.source) }
+ }
+ }
+ // swapSource layer id existence (if used)
+ statesParent?.children?.forEach { st ->
+ st.children.filter { it.name.equals("swapSource", true) }.forEach { swap ->
+ val layerId = swap.attributes["layerId"]
+ if (layerId.isNullOrBlank()) {
+ out += ValidationIssue(ValidationCode.REQUIRED_FIELD_MISSING, Severity.ERROR, "swapSource requires layerId.", swap.source)
+ } else {
+ val layerExists = cardNode.children.any { child ->
+ when (child.name.lowercase(Locale.ROOT)) {
+ "image", "video", "overlay", "shadow", "border", "text", "backdrop" -> child.attributes["id"] == layerId
+ else -> false
+ }
+ }
+ if (!layerExists) {
+ out += ValidationIssue(ValidationCode.UNKNOWN_LAYER_REF, Severity.ERROR, "swapSource layerId '$layerId' not found in card '${cardNode.attributes["id"]}'.", swap.source)
+ }
+ }
+ }
+ }
+ }
+
+ private fun validateImageNode(tree: ThemeTree, node: XmlNode, out: MutableList) {
+ val src = node.attributes["src"]
+ if (src.isNullOrBlank()) {
+ out += ValidationIssue(ValidationCode.MISSING_MEDIA_SRC, Severity.ERROR, " requires 'src'.", node.source)
+ return
+ }
+ // Best-effort asset presence for literal relative paths
+ checkAssetPresenceIfLiteral(tree, node, attrName = "src", out)
+ // Optional fallback
+ checkAssetPresenceIfLiteral(tree, node, attrName = "fallback", out, warnMissing = true)
+ }
+
+ private fun validateVideoNode(tree: ThemeTree, node: XmlNode, out: MutableList) {
+ val src = node.attributes["src"]
+ if (src.isNullOrBlank()) {
+ out += ValidationIssue(ValidationCode.MISSING_MEDIA_SRC, Severity.ERROR, "