Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
maufeat
469c690f5f headers 2026-02-12 17:17:15 +01:00
maufeat
0aacd0fadb initial rework of the launcher, using kotlin compose 2026-02-12 17:12:32 +01:00
35 changed files with 2770 additions and 537 deletions

View file

@ -5,8 +5,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// import android.annotation.SuppressLint
import com.android.build.gradle.api.ApplicationVariant
import kotlin.collections.setOf
import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
import com.github.triplet.gradle.androidpublisher.ReleaseStatus
import org.gradle.api.tasks.Copy
@ -15,7 +13,8 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.9.20"
id("org.jetbrains.kotlin.plugin.compose")
kotlin("plugin.serialization") version "2.3.0"
id("androidx.navigation.safeargs.kotlin")
id("org.jlleitschuh.gradle.ktlint") version "11.4.0"
id("com.github.triplet.play") version "3.8.6"
@ -43,6 +42,7 @@ android {
buildFeatures {
viewBinding = true
compose = true
}
compileOptions {
@ -50,8 +50,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
packaging {
@ -65,7 +67,7 @@ android {
defaultConfig {
applicationId = "dev.eden.eden_emulator"
minSdk = 24
minSdk = 26
targetSdk = 36
versionName = getGitVersion()
versionCode = autoVersion
@ -267,6 +269,10 @@ android {
}
}
composeCompiler {
reportsDestination = layout.buildDirectory.dir("compose_compiler")
}
idea {
module {
// Inclusion to exclude build/ dir from non-Android
@ -292,7 +298,7 @@ tasks.getByPath("ktlintMainSourceSetCheck").doFirst { showFormatHelp.invoke() }
tasks.getByPath("loadKtlintReporters").dependsOn("ktlintReset")
ktlint {
version.set("0.47.1")
version.set("0.50.0")
android.set(true)
ignoreFailures.set(false)
disabledRules.set(
@ -317,29 +323,42 @@ play {
}
dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.compose:compose-bom:2026.01.01")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3:1.4.0")
implementation("androidx.activity:activity-compose:1.12.3")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
implementation("androidx.navigation:navigation-compose:2.9.7")
implementation("io.coil-kt:coil-compose:2.7.0")
implementation("io.coil-kt:coil-svg:2.7.0")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.core:core-ktx:1.17.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation("androidx.fragment:fragment-ktx:1.8.6")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.fragment:fragment-ktx:1.8.9")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("com.google.android.material:material:1.13.0")
implementation("androidx.preference:preference-ktx:1.2.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("io.coil-kt:coil:2.2.2")
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.2")
implementation("androidx.window:window:1.3.0")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation("org.commonmark:commonmark:0.22.0")
implementation("androidx.navigation:navigation-fragment-ktx:2.8.9")
implementation("androidx.navigation:navigation-ui-ktx:2.8.9")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.10.0")
implementation("com.squareup.okhttp3:okhttp:5.3.2")
implementation("io.coil-kt:coil:2.7.0")
implementation("androidx.core:core-splashscreen:1.2.0")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.21.0")
implementation("androidx.window:window:1.5.1")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0")
implementation("org.commonmark:commonmark:0.27.1")
implementation("androidx.navigation:navigation-fragment-ktx:2.9.7")
implementation("androidx.navigation:navigation-ui-ktx:2.9.7")
implementation("info.debatty:java-string-similarity:2.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
implementation("androidx.compose.ui:ui-graphics-android:1.7.8")
implementation("androidx.compose.ui:ui-text-android:1.7.8")
implementation("net.swiftzer.semver:semver:2.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation("androidx.compose.ui:ui-graphics-android:1.10.2")
implementation("androidx.compose.ui:ui-text-android:1.10.2")
implementation("net.swiftzer.semver:semver:2.1.0")
}
fun runGitCommand(command: List<String>): String {

View file

@ -0,0 +1,230 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="512"
height="512"
fill="none"
viewBox="0 0 512 512"
version="1.1"
id="svg7"
sodipodi:docname="base.svg.2026_01_12_14_43_47.0.svg"
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
inkscape:export-filename="base.svg.2026_01_12_14_43_47.0.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs7">
<linearGradient
id="linearGradient1"
inkscape:collect="always">
<stop
style="stop-color:#ff2e88;stop-opacity:0.5;"
offset="0"
id="stop3" />
<stop
style="stop-color:#bf42f6;stop-opacity:0.5;"
offset="0.44631511"
id="stop4" />
<stop
style="stop-color:#5da5ed;stop-opacity:0.5;"
offset="0.90088946"
id="stop2" />
</linearGradient>
<linearGradient
id="linearGradient138"
inkscape:collect="always">
<stop
style="stop-color:#ff2e88;stop-opacity:1;"
offset="0"
id="stop152" />
<stop
style="stop-color:#bf42f6;stop-opacity:1;"
offset="0.44971901"
id="stop137" />
<stop
style="stop-color:#5da5ed;stop-opacity:1;"
offset="0.89793283"
id="stop138" />
</linearGradient>
<linearGradient
id="swatch37"
inkscape:swatch="solid">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop37" />
</linearGradient>
<linearGradient
id="swatch28"
inkscape:swatch="solid">
<stop
style="stop-color:#252525;stop-opacity:1;"
offset="0"
id="stop28" />
</linearGradient>
<linearGradient
id="swatch27"
inkscape:swatch="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop27" />
</linearGradient>
<linearGradient
id="swatch15"
inkscape:swatch="solid">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop16" />
</linearGradient>
<linearGradient
id="linearGradient14"
inkscape:swatch="gradient">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop14" />
<stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop15" />
</linearGradient>
<linearGradient
id="swatch9"
inkscape:swatch="solid">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop10" />
</linearGradient>
<linearGradient
id="swatch8"
inkscape:swatch="solid">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0"
id="stop9" />
</linearGradient>
<rect
x="22.627417"
y="402.76802"
width="521.34025"
height="248.94868"
id="rect24" />
<linearGradient
id="linearGradient11"
inkscape:collect="always">
<stop
style="stop-color:#ff2e88;stop-opacity:1;"
offset="0"
id="stop11" />
<stop
style="stop-color:#bf42f6;stop-opacity:1;"
offset="0.44971901"
id="stop154" />
<stop
style="stop-color:#5da5ed;stop-opacity:1;"
offset="0.89793283"
id="stop12" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient138"
id="linearGradient6"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.118028,0,0,1.116699,-46.314723,-42.388667)"
x1="270.39996"
y1="40.000019"
x2="270.39996"
y2="494.39996"
spreadMethod="pad" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath18">
<circle
style="opacity:1;mix-blend-mode:normal;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10.8382;stroke-opacity:0.566238;paint-order:stroke fill markers"
id="circle18"
cx="-246.8315"
cy="246.8338"
inkscape:label="Circle"
r="191.89999" />
</clipPath>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath22">
<circle
style="opacity:1;mix-blend-mode:normal;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:10.8382;stroke-opacity:0.566238;paint-order:stroke fill markers"
id="circle22"
cx="256"
cy="256"
inkscape:label="Circle"
r="191.89999" />
</clipPath>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient11"
id="linearGradient27"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-6.9401139e-5,-2.8678628)"
x1="256.00012"
y1="102.94693"
x2="256.00012"
y2="409.05307" />
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath128">
<circle
style="fill:none;fill-opacity:1;stroke:#03ffff;stroke-width:0;stroke-dasharray:none;stroke-opacity:1"
id="circle128"
cx="256"
cy="256"
r="192" />
</clipPath>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1"
id="linearGradient2"
x1="256"
y1="64"
x2="256"
y2="448"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(1.3229974,0,0,1.3214002,-82.687336,-82.290326)" />
</defs>
<sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.4142136"
inkscape:cx="261.62951"
inkscape:cy="230.87036"
inkscape:window-width="1920"
inkscape:window-height="1008"
inkscape:window-x="1080"
inkscape:window-y="351"
inkscape:window-maximized="1"
inkscape:current-layer="svg7" />
<path
id="path8-7"
style="display:inline;mix-blend-mode:multiply;fill:url(#linearGradient6);fill-opacity:1;fill-rule:nonzero;stroke:url(#linearGradient2);stroke-width:3.9666;stroke-dasharray:none;stroke-opacity:0.566238;paint-order:stroke fill markers"
inkscape:label="Circle"
d="M 256,2.2792898 A 254.0155,253.71401 0 0 0 150.68475,25.115202 c 19.54414,1.070775 38.74692,5.250294 51.56848,11.647658 14.14361,7.056691 28.63804,19.185961 39.4212,29.347551 h 40.60981 c 1.03847,-0.68139 2.10297,-1.36938 3.1938,-2.05957 5.45602,-15.78533 14.79164,-43.183497 19.49612,-57.0097682 A 254.0155,253.71401 0 0 0 256,2.2792898 Z m 61.57106,7.567234 -18.26098,46.1544672 c 7.79702,-4.13918 16.35655,-7.87447 25.20671,-10.87081 23.1229,-7.828433 43.96931,-10.170904 54.94058,-10.868226 A 254.0155,253.71401 0 0 0 317.57106,9.8465238 Z m 65.39277,26.4001532 c -9.68256,4.806644 -33.05532,16.642034 -55.68217,29.863734 H 424.4677 A 254.0155,253.71401 0 0 0 382.96383,36.246677 Z M 113.90698,45.690231 A 254.0155,253.71401 0 0 0 87.532302,66.110411 H 194.2739 c -1.47402,-0.80231 -2.35141,-1.25949 -2.35141,-1.25949 l 10.4496,-11.83348 -38.40568,7.01234 c 0,1e-5 -12.21537,-4.60266 -40.17313,-12.27223 -3.45336,-0.94731 -6.75329,-1.61824 -9.8863,-2.06732 z m -36.803618,30.18635 a 254.0155,253.71401 0 0 0 -34.88372,43.090929 h 59.976738 c 18.11461,-12.04145 40.14252,-22.882149 62.31266,-24.534159 52.93006,-3.9444 70.16538,1.86342 70.16538,1.86342 0,0 -4.612,-4.8206 -14.51938,-13.36656 -2.72366,-2.34942 -6.0844,-4.77373 -9.52455,-7.05363 z m 174.472868,0 c 4.57322,4.7186 7.29716,7.83565 7.29716,7.83565 0,0 3.53501,-3.18484 9.62532,-7.83565 z m 60.27649,0 c -21.56573,15.45339 -25.4703,27.979669 -25.4703,27.979669 0,0 54.83326,-19.215729 100.70543,-0.31228 11.63986,4.79661 21.58481,10.13159 29.94832,15.42354 h 52.74419 A 254.0155,253.71401 0 0 0 434.89664,75.876581 Z M 36.250648,128.73367 A 254.0155,253.71401 0 0 0 16.372095,171.82459 H 147.45478 c 1.45695,-2.5815 3.06539,-5.08648 4.83979,-7.48982 14.23694,-19.28301 27.92088,-30.0088 36.86047,-35.6011 h -30.25323 c -5.87346,0.93472 -12.04945,1.99094 -18.28166,3.16937 -30.12936,5.69727 -81.157618,22.78945 -81.157618,22.78945 0,0 11.47125,-12.39249 29.11369,-25.95882 z m 265.630492,0 c 33.48676,11.2434 52.42799,26.78443 62.7752,43.09092 h 130.97157 a 254.0155,253.71401 0 0 0 -19.87856,-43.09092 h -44.81136 c 14.85233,11.5863 21.59948,20.9854 21.59948,20.9854 0,0 -33.5226,-12.37087 -66.0646,-20.9854 z m -45.96641,16.27007 c -1.00419,0.0106 -10.12705,0.72026 -44.98966,20.64729 -3.12132,1.78406 -6.25434,3.86182 -9.37468,6.17356 h 41.81911 c 7.17181,-17.34774 12.64083,-26.82085 12.64083,-26.82085 0,0 -0.0287,-7.1e-4 -0.0957,0 z m 14.18088,0.0465 c 0,0 -3.31228,9.32762 -7.30492,26.77438 h 51.78554 C 287.6577,146.14158 270.09561,145.0502 270.09561,145.0502 Z M 13.152456,181.59075 A 254.0155,253.71401 0 0 0 3.927651,224.68167 H 134.1447 c 0.56161,-12.72411 2.67825,-28.50188 8.61499,-43.09092 z m 176.661504,0 c -14.27121,13.10564 -27.60733,29.58761 -37.56073,43.09092 h 73.3721 c 4.47018,-16.79061 9.35068,-31.26371 13.86562,-43.09092 z m 70.85787,0 c -2.41384,11.76417 -4.9032,26.20707 -6.94831,43.09092 H 360.4832 c -8.32133,-10.88917 -20.66988,-26.17008 -36.35141,-43.09092 z m 109.17313,0 c 6.63611,15.24089 6.92441,30.5373 5.57882,43.09092 h 132.64857 a 254.0155,253.71401 0 0 0 -9.22481,-43.09092 z M 2.90181,234.44783 A 254.0155,253.71401 0 0 0 1.984498,255.9933 254.0155,253.71401 0 0 0 2.90181,277.53876 h 211.89923 c 2.25762,-15.52555 5.14325,-29.93448 8.3385,-43.09093 h -77.8863 c -6.46396,9.27617 -10.33076,15.56549 -10.33076,15.56549 0,0 -0.82623,-6.14945 -0.9354,-15.56549 z m 249.72093,0 c -1.3692,13.09684 -2.4456,27.49209 -3.02068,43.09093 h 259.49613 a 254.0155,253.71401 0 0 0 0.91731,-21.54546 254.0155,253.71401 0 0 0 -0.91731,-21.54547 H 374.02584 c -0.445,2.5469 -0.90878,4.89768 -1.32817,7.01751 0,0 -1.69726,-2.53821 -4.94056,-7.01751 z M 3.927651,287.30493 a 254.0155,253.71401 0 0 0 9.224805,43.09091 H 214.04393 c -1.29238,-15.40742 -1.57503,-30.04388 -0.41861,-43.09091 z m 245.385009,0 c -0.30355,13.54349 -0.22032,27.92598 0.36951,43.09091 h 249.16537 a 254.0155,253.71401 0 0 0 9.22481,-43.09091 z M 16.369511,340.16201 a 254.0155,253.71401 0 0 0 19.878554,43.09091 H 221.4677 c -2.69781,-14.4523 -4.96108,-29.01285 -6.4832,-43.09091 z m 233.842379,0 c 1.15864,15.47765 3.81286,29.83979 7.51679,43.09091 h 218.02325 a 254.0155,253.71401 0 0 0 19.87856,-43.09091 z M 42.217052,393.01909 a 254.0155,253.71401 0 0 0 34.88372,43.09093 H 233.09561 c -3.40902,-13.67281 -6.76794,-28.2531 -9.73902,-43.09093 z m 218.490958,0 c 5.34985,16.15926 12.22007,30.51982 19.68733,43.09093 h 154.50389 a 254.0155,253.71401 0 0 0 34.88371,-43.09093 z M 87.529722,445.87618 a 254.0155,253.71401 0 0 0 166.229968,63.8208 c -3.67805,-12.0825 -10.85464,-35.49828 -18.18088,-63.8208 z m 199.010328,0 c 17.5887,26.43772 36.99259,43.60598 47.33592,51.61309 a 254.0155,253.71401 0 0 0 90.59431,-51.61309 z" />
<path
id="path27"
style="display:inline;mix-blend-mode:multiply;fill:none;fill-opacity:1;fill-rule:evenodd;stroke:url(#linearGradient27);stroke-width:3;stroke-linejoin:round;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke fill markers"
d="m 318.98012,441.7375 c -9.87518,-6.73978 -64.39137,-49.0272 -67.68975,-127.81978 -3.69298,-88.21893 15.36468,-141.91029 15.36468,-141.91029 0,0 16.00378,0.99513 39.80316,26.53195 23.79939,25.53753 37.74965,46.43102 37.74965,46.43102 3.91262,-19.79992 12.84563,-66.32402 -60.72865,-87.55523 0,0 12.82326,-5.38883 39.3925,-3.81382 26.56907,1.57572 81.6822,21.93799 81.6822,21.93799 0,0 -14.79766,-20.63773 -49.47063,-34.94295 -34.67291,-14.30533 -76.1182,0.23644 -76.1182,0.23644 0,0 3.86959,-12.43127 27.22669,-26.38478 23.35718,-13.9537 49.27409,-26.501533 49.27409,-26.501533 0,0 -21.97854,-0.26548 -47.67725,8.44535 -6.68948,2.267506 -13.15863,5.094213 -19.05208,8.226563 l 16.05803,-40.634103 -4.4617,-1.89059 -5.1305,-0.95965 c 0,0 -11.24072,33.12428 -16.92051,49.576513 -12.13137,7.68489 -20.11005,14.87735 -20.11005,14.87735 0,0 -21.90573,-25.09227 -42.79668,-35.527803 -26.03412,-13.00525 -86.88249,-13.90359 -94.0044,10.401173 0,0 13.56804,-7.884703 34.70032,-2.080917 21.13214,5.803997 30.3644,9.287307 30.3644,9.287307 l 29.02989,-5.30681 -7.89811,8.95527 c 0,0 13.8496,7.21324 21.33822,13.68063 7.48859,6.46722 10.9757,10.11472 10.9757,10.11472 0,0 -13.02739,-4.39388 -53.03507,-1.40893 -40.00771,2.98473 -79.40016,45.60209 -79.40016,45.60209 0,0 38.57037,-12.93531 61.34393,-17.24677 22.77354,-4.31126 44.52166,-6.46757 44.52166,-6.46757 0,0 -17.23298,5.97003 -35.69792,31.00932 -18.46522,25.03987 -13.13146,64.83866 -13.13146,64.83866 0,0 29.33874,-47.7577 57.44675,-63.84249 28.10798,-16.08527 34.0799,-15.6238 34.0799,-15.6238 0,0 -22.56785,39.13486 -31.39017,101.98268 -8.03005,57.2039 26.77689,163.75449 31.1572,178.89699"
sodipodi:nodetypes="cscsccscscscsccccccscscccscscscscscsc"
inkscape:label="MainOutline"
clip-path="url(#clipPath128)"
transform="matrix(1.3229974,0,0,1.3214002,-82.687282,-82.278451)" />
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,184 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.background
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.decode.SvgDecoder
import coil.request.ImageRequest
import dev.eden.emu.ui.theme.EdenTheme
import kotlin.math.pow
@Composable
fun RetroGridBackground(
modifier: Modifier = Modifier,
lineColor: Color = EdenTheme.colors.primary.copy(alpha = 0.2f),
spacing: Dp = 40.dp,
lineWidth: Dp = 2.dp,
speedDpPerSecond: Float = 12f,
fadeColor: Color = EdenTheme.colors.background,
logoScale: Float = 0.35f,
logoAlpha: Float = 0.25f,
horizonRatio: Float = 0.45f,
logoAssetPath: String = "file:///android_asset/base.svg", // correct way?
logoWidth: Dp? = null,
logoHeight: Dp? = null,
logoCropBottomRatio: Float = 0.33f,
) {
val context = LocalContext.current
val density = LocalDensity.current
val durationMs = remember(spacing, speedDpPerSecond, density) {
val spacingPx = spacing.value * density.density
val speedPxPerSecond = speedDpPerSecond * density.density
((spacingPx / speedPxPerSecond) * 1000f).toInt().coerceAtLeast(300)
}
val transition = rememberInfiniteTransition(label = "grid")
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = durationMs, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "gridPhase",
)
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val baseSize = maxWidth * logoScale
val resolvedWidth = logoWidth ?: baseSize
val resolvedHeight = logoHeight ?: baseSize
val cropRatio = logoCropBottomRatio.coerceIn(0f, 0.9f)
val visibleHeight = resolvedHeight * (1f - cropRatio)
val visibleHeightPx = with(density) { visibleHeight.toPx() }
val horizonY = maxHeight * horizonRatio
val logoOffsetY = horizonY - (resolvedHeight * 0.5f) - 50.dp
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.drawWithCache {
val spacingPx = spacing.toPx().coerceAtLeast(8f)
val strokePx = lineWidth.toPx().coerceAtLeast(1f)
val horizonScale = 0.18f
val overscan = 5.0f
val perspectivePower = 2.2f
onDrawBehind {
val width = size.width
val height = size.height
val centerX = width * 0.5f
val horizonYpx = height * horizonRatio
val depth = (height - horizonYpx).coerceAtLeast(1f)
val offset = (1f - phase) * spacingPx
val baseHalf = centerX * overscan
val horizonHalf = baseHalf * horizonScale
val lineCount = (depth / (spacingPx * 0.65f))
.toInt()
.coerceAtLeast(20)
var x = centerX - baseHalf - spacingPx * 6f
while (x <= centerX + baseHalf + spacingPx * 6f) {
val endX = centerX + (x - centerX) * horizonScale
drawLine(
color = lineColor,
start = Offset(x, height),
end = Offset(endX, horizonYpx),
strokeWidth = strokePx,
)
x += spacingPx
}
var i = 0
while (i <= lineCount) {
val z = ((i + (offset / spacingPx)) / lineCount.toFloat())
.coerceIn(0f, 1f)
val zCurve = z.toDouble().pow(perspectivePower.toDouble()).toFloat()
val y = horizonYpx + depth * zCurve
val halfWidth = horizonHalf + (baseHalf - horizonHalf) * z
drawLine(
color = lineColor,
start = Offset(centerX - halfWidth, y),
end = Offset(centerX + halfWidth, y),
strokeWidth = strokePx,
)
i += 1
}
val fadeHeight = height * 0.12f
val solidHeight = (horizonYpx - fadeHeight).coerceAtLeast(0f)
if (solidHeight > 0f) {
drawRect(
color = fadeColor,
topLeft = Offset(0f, 0f),
size = Size(width, solidHeight),
)
}
drawRect(
brush = Brush.verticalGradient(
colors = listOf(fadeColor, Color.Transparent),
startY = horizonYpx - fadeHeight,
endY = horizonYpx + fadeHeight,
),
topLeft = Offset(0f, horizonYpx - fadeHeight),
size = Size(width, fadeHeight * 2f),
)
}
},
)
AsyncImage(
model = ImageRequest.Builder(context)
.data(logoAssetPath)
.decoderFactory(SvgDecoder.Factory())
.build(),
contentDescription = null,
modifier = Modifier
.align(Alignment.TopCenter)
.requiredWidth(resolvedWidth)
.requiredHeight(resolvedHeight)
.offset(y = logoOffsetY)
.drawWithContent {
clipRect(
left = 0f,
top = 0f,
right = size.width,
bottom = visibleHeightPx,
) {
this@drawWithContent.drawContent()
}
}
.graphicsLayer(alpha = logoAlpha),
)
}
}
}

View file

@ -0,0 +1,157 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.model.Game
import org.yuzu.yuzu_emu.utils.GameIconUtils
import dev.eden.emu.ui.theme.EdenTheme
import dev.eden.emu.ui.theme.Shapes
import dev.eden.emu.ui.utils.ConfirmKeys
import dev.eden.emu.ui.utils.MenuKeys
import java.util.Locale
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EdenTile(
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: (() -> Unit)? = null,
imageUri: String? = null,
iconSize: Dp = 140.dp,
tileHeight: Dp = 168.dp,
useLargerFont: Boolean = false,
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused = interactionSource.collectIsFocusedAsState().value
val borderColor = if (isFocused) EdenTheme.colors.primary else EdenTheme.colors.surface
Column(
modifier = modifier
.width(iconSize)
.height(tileHeight)
.semantics { role = Role.Button }
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyUp) {
when {
e.key in ConfirmKeys -> { onClick(); true }
e.key in MenuKeys -> { onLongClick?.invoke(); true }
else -> false
}
} else false
}
.focusable(interactionSource = interactionSource)
.combinedClickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
onLongClick = onLongClick,
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Game icon
Box(
modifier = Modifier
.size(iconSize)
.background(EdenTheme.colors.surface, Shapes.medium)
.border(BorderStroke(3.dp, borderColor), Shapes.medium)
.clip(Shapes.medium),
contentAlignment = Alignment.Center,
) {
if (imageUri.isNullOrBlank()) {
BasicText(
text = title.take(1).uppercase(Locale.getDefault()),
style = EdenTheme.typography.title.copy(color = EdenTheme.colors.onSurface),
)
} else {
AndroidView(
factory = { ctx ->
android.widget.ImageView(ctx).apply {
layoutParams = android.view.ViewGroup.LayoutParams(-1, -1)
scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
setImageResource(R.drawable.default_icon)
GameIconUtils.loadGameIcon(Game(title, imageUri, "0", "", "", false), this)
}
},
modifier = Modifier.fillMaxSize()
)
}
}
// Game title with marquee
MarqueeText(title, isFocused, 32.dp, useLargerFont, Modifier.width(iconSize))
}
}
@Composable
private fun MarqueeText(
text: String,
isAnimating: Boolean,
height: Dp,
useLargerFont: Boolean,
modifier: Modifier = Modifier,
) {
val style = if (useLargerFont) {
EdenTheme.typography.body.copy(Color.White, 18.sp, fontWeight = FontWeight.Bold)
} else {
EdenTheme.typography.label.copy(Color.White)
}
Box(modifier.height(height).clipToBounds(), Alignment.Center) {
BasicText(
text = text,
style = style,
maxLines = 1,
softWrap = false,
overflow = TextOverflow.Ellipsis,
modifier = if (isAnimating) {
Modifier.basicMarquee(
iterations = Int.MAX_VALUE,
animationMode = MarqueeAnimationMode.Immediately,
initialDelayMillis = 150, // does not seem to take effect
repeatDelayMillis = 1000,
velocity = 30.dp,
)
} else Modifier,
)
}
}

View file

@ -0,0 +1,259 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
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 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.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import org.yuzu.yuzu_emu.R
import dev.eden.emu.ui.theme.Dimens
import dev.eden.emu.ui.theme.EdenTheme
import dev.eden.emu.ui.utils.ConfirmKeys
/**
* Expandable search bar for controller-based navigation.
* ________________________________________________________________
* Note: Focus Manager on this doesn't work good. Need a hide impl.
* when it looses focus for better Controller Experience
* ________________________________________________________________
*/
@Composable
fun ExpandableSearchBar(
query: String,
onQueryChange: (String) -> Unit,
isExpanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val searchHint = context.getString(R.string.home_search_games)
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val iconInteractionSource = remember { MutableInteractionSource() }
val isIconFocused by iconInteractionSource.collectIsFocusedAsState()
// Track if we have an active filter (submitted search, if keyboard just hides, it kinda breaks)
val hasActiveFilter = query.isNotEmpty() && !isExpanded
val textFieldFocusRequester = remember { FocusRequester() }
// Focus text field when expanded
LaunchedEffect(isExpanded) {
if (isExpanded) {
delay(100)
try {
textFieldFocusRequester.requestFocus()
keyboardController?.show()
} catch (_: Exception) {}
}
}
// Use a fixed width container to prevent layout shift.
Box(
modifier = modifier.width(if (isExpanded) 300.dp else 48.dp),
contentAlignment = Alignment.CenterEnd,
) {
if (isExpanded) {
Row(
modifier = Modifier
.width(300.dp)
.clip(RoundedCornerShape(24.dp))
.background(EdenTheme.colors.surface)
.border(
width = 2.dp,
color = EdenTheme.colors.primary,
shape = RoundedCornerShape(24.dp)
),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.width(16.dp))
// Text input - NO onFocusChanged to prevent auto-close issues
// (Needs to be "submitted", hiding keyboard breaks focus flow)
BasicTextField(
value = query,
onValueChange = onQueryChange,
modifier = Modifier
.weight(1f)
.focusRequester(textFieldFocusRequester)
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyUp) {
when (keyEvent.key) {
Key.Escape, Key.Back -> {
onQueryChange("")
onExpandedChange(false)
keyboardController?.hide()
focusManager.clearFocus()
true
}
Key.Enter, Key.NumPadEnter -> {
keyboardController?.hide()
onExpandedChange(false)
focusManager.clearFocus()
true
}
else -> false
}
} else false
},
textStyle = TextStyle(
color = Color.White,
fontSize = 16.sp,
),
cursorBrush = SolidColor(EdenTheme.colors.primary),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
keyboardController?.hide()
onExpandedChange(false)
focusManager.clearFocus()
}
),
decorationBox = { innerTextField ->
Box(
modifier = Modifier.padding(vertical = 14.dp),
contentAlignment = Alignment.CenterStart,
) {
if (query.isEmpty()) {
androidx.compose.foundation.text.BasicText(
text = searchHint,
style = TextStyle(
color = Color.White.copy(alpha = 0.5f),
fontSize = 16.sp,
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
innerTextField()
}
}
)
// Clear/Close button (X icon)
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.clickable {
onQueryChange("")
onExpandedChange(false)
keyboardController?.hide()
focusManager.clearFocus()
}
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_clear),
contentDescription = context.getString(R.string.home_clear),
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp),
)
}
Spacer(modifier = Modifier.width(4.dp))
}
} else {
// Collapsed state: search icon
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(
if (isIconFocused) EdenTheme.colors.primary
else EdenTheme.colors.surface
)
.focusRequester(focusRequester)
.focusable(interactionSource = iconInteractionSource)
.clickable(
interactionSource = iconInteractionSource,
indication = null,
) {
if (hasActiveFilter) {
onQueryChange("")
} else {
onExpandedChange(true)
}
}
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyUp && keyEvent.key in ConfirmKeys) {
if (hasActiveFilter) {
onQueryChange("")
} else {
onExpandedChange(true)
}
true
} else false
},
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_search),
contentDescription = context.getString(R.string.home_search),
tint = if (hasActiveFilter) EdenTheme.colors.primary else Color.White,
modifier = Modifier.size(24.dp),
)
// Active filter indicator dot
if (hasActiveFilter) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.size(10.dp)
.clip(CircleShape)
.background(EdenTheme.colors.primary)
)
}
}
}
}
}

View file

@ -0,0 +1,319 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import androidx.compose.foundation.gestures.snapping.SnapPosition
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.graphicsLayer
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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlin.math.abs
import kotlin.math.cos
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun GameCarousel(
gameTiles: List<GameTile>,
onGameClick: (GameTile) -> Unit,
onGameLongClick: (GameTile) -> Unit = {},
focusRequester: FocusRequester,
onNavigateUp: () -> Unit = {},
modifier: Modifier = Modifier,
initialFocusedIndex: Int = 0,
onFocusedIndexChanged: (Int) -> Unit = {},
onShowGameInfo: (GameTile) -> Unit = {},
) {
if (gameTiles.isEmpty()) return
val safeInitialIndex = initialFocusedIndex.coerceIn(0, (gameTiles.size - 1).coerceAtLeast(0))
val listState = rememberLazyListState(
initialFirstVisibleItemIndex = safeInitialIndex,
initialFirstVisibleItemScrollOffset = 0
)
val snapFlingBehavior = rememberSnapFlingBehavior(
lazyListState = listState,
snapPosition = SnapPosition.Center
)
val coroutineScope = rememberCoroutineScope()
var centerItemIndex by remember { mutableIntStateOf(safeInitialIndex) }
LaunchedEffect(centerItemIndex) {
onFocusedIndexChanged(centerItemIndex)
}
suspend fun centerOnIndex(index: Int, animate: Boolean = true) {
if (gameTiles.isEmpty()) return
if (listState.layoutInfo.visibleItemsInfo.none { it.index == index }) {
if (animate) {
listState.animateScrollToItem(index)
} else {
listState.scrollToItem(index)
}
}
delay(10)
val layoutInfo = listState.layoutInfo
val itemInfo = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } ?: return
val viewportCenter = (layoutInfo.viewportStartOffset + layoutInfo.viewportEndOffset) / 2
val desiredStart = viewportCenter - itemInfo.size / 2
if (itemInfo.offset != desiredStart) {
val scrollOffset = -(viewportCenter - itemInfo.size / 2)
if (animate) {
listState.animateScrollToItem(index, scrollOffset = scrollOffset)
} else {
listState.scrollToItem(index, scrollOffset = scrollOffset)
}
}
}
LaunchedEffect(Unit) {
listState.scrollToItem(safeInitialIndex)
delay(50)
centerOnIndex(safeInitialIndex, animate = false)
}
val focusRequesters = remember(gameTiles.size) {
List(gameTiles.size) { FocusRequester() }
}
var isFocusTriggeredScroll by remember { mutableStateOf(false) }
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress }
.collect { isScrolling ->
if (!isScrolling && !isFocusTriggeredScroll && gameTiles.isNotEmpty()) {
val firstVisible = listState.firstVisibleItemIndex
val scrollOffset = listState.firstVisibleItemScrollOffset
val layoutInfo = listState.layoutInfo
val firstVisibleItem = layoutInfo.visibleItemsInfo.firstOrNull()
val itemSize = firstVisibleItem?.size ?: 1
val newCenterIndex = if (scrollOffset > itemSize / 2) {
(firstVisible + 1).coerceAtMost(gameTiles.size - 1)
} else {
firstVisible
}
if (newCenterIndex != centerItemIndex && newCenterIndex in focusRequesters.indices) {
centerItemIndex = newCenterIndex
try {
focusRequesters[newCenterIndex].requestFocus()
} catch (_: Exception) { }
}
}
// Reset the flag when scroll stops
if (!isScrolling) {
isFocusTriggeredScroll = false
}
}
}
// Carousel settings (matching original CarouselRecyclerView)
val borderScale = 0.6f
val borderAlpha = 0.35f
val overlapFactor = 0.15f
BoxWithConstraints(
modifier = modifier
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
val density = LocalDensity.current
val screenWidthPx = with(density) { maxWidth.toPx() }
val screenHeightPx = with(density) { maxHeight.toPx() }
val tileTextHeight = 40.dp
val tileSpacing = 6.dp
// Card size
val totalTextAreaPx = with(density) { (tileTextHeight + tileSpacing).toPx() }
val cardSizePx = (screenHeightPx - totalTextAreaPx) * 0.7f
val cardSize = with(density) { cardSizePx.toDp() }
val tileHeight = cardSize + tileSpacing + tileTextHeight
// Horizontal padding to center first/last card
val horizontalPaddingPx = (screenWidthPx - cardSizePx) / 2f
val horizontalPadding = with(density) { horizontalPaddingPx.toDp() }
// Negative spacing for overlap, no overlap currently tho
val overlapPx = cardSizePx * overlapFactor
val itemSpacing = with(density) { (-overlapPx).toDp() }
val centerPushPx = cardSizePx * 0.12f
val screenCenterPx = screenWidthPx / 2f
val itemWidthWithSpacing = cardSizePx - overlapPx
LazyRow(
state = listState,
modifier = Modifier.fillMaxSize(),
flingBehavior = snapFlingBehavior,
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
contentPadding = PaddingValues(start = horizontalPadding, end = horizontalPadding, top = 30.dp),
verticalAlignment = Alignment.CenterVertically,
) {
itemsIndexed(gameTiles, key = { _, tile -> tile.id }) { index, tile ->
val distanceFromCenter by remember {
derivedStateOf {
val itemInfo = listState.layoutInfo.visibleItemsInfo.find { it.index == index }
if (itemInfo == null) {
1f
} else {
val itemLeftInScreen = itemInfo.offset.toFloat() + horizontalPaddingPx
val itemCenterInScreen = itemLeftInScreen + itemInfo.size / 2f
val distance = abs(itemCenterInScreen - screenCenterPx)
(distance / screenCenterPx).coerceIn(0f, 1f)
}
}
}
val shapedScale = cos(distanceFromCenter * Math.PI * 0.5).toFloat().coerceIn(0f, 1f)
val scale = borderScale + (1f - borderScale) * shapedScale
val shapedAlpha = cos(distanceFromCenter * Math.PI * 0.5).toFloat().coerceIn(0f, 1f)
val alpha = borderAlpha + (1f - borderAlpha) * shapedAlpha
val zIndex = 100f * (1f - distanceFromCenter)
val itemInfo = listState.layoutInfo.visibleItemsInfo.find { it.index == index }
val isLeftOfCenter = if (itemInfo != null) {
val itemLeftInScreen = itemInfo.offset.toFloat() + horizontalPaddingPx
val itemCenterInScreen = itemLeftInScreen + itemInfo.size / 2f
itemCenterInScreen < screenCenterPx
} else {
index < (gameTiles.size / 2)
}
val pushFactor = (1f - distanceFromCenter) * distanceFromCenter * 4f
val horizontalPush = centerPushPx * pushFactor * (if (isLeftOfCenter) -1f else 1f)
val tileModifier = if (index == centerItemIndex) {
Modifier
.focusRequester(focusRequester)
.focusRequester(focusRequesters[index])
} else {
Modifier.focusRequester(focusRequesters[index])
}
Box(
modifier = Modifier
.size(width = cardSize, height = tileHeight)
.zIndex(zIndex)
.onFocusChanged { focusState ->
if (focusState.hasFocus && centerItemIndex != index) {
isFocusTriggeredScroll = true
centerItemIndex = index
coroutineScope.launch {
centerOnIndex(index, animate = true)
}
}
}
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown) {
when (keyEvent.key) {
Key.ButtonX -> {
onShowGameInfo(tile)
true
}
Key.DirectionUp, Key.DirectionUpLeft, Key.DirectionUpRight -> {
onNavigateUp()
true
}
Key.DirectionLeft -> {
if (index > 0) {
focusRequesters[index - 1].requestFocus()
}
true
}
Key.DirectionRight -> {
if (index < gameTiles.size - 1) {
focusRequesters[index + 1].requestFocus()
}
true
}
else -> false
}
} else {
false
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
translationX = horizontalPush
},
contentAlignment = Alignment.Center,
) {
EdenTile(
title = tile.title,
iconSize = cardSize,
tileHeight = tileHeight,
onClick = {
if (index == centerItemIndex) {
onGameClick(tile)
} else {
isFocusTriggeredScroll = true
centerItemIndex = index
coroutineScope.launch {
centerOnIndex(index, animate = true)
}
focusRequesters[index].requestFocus()
}
},
onLongClick = {
if (index == centerItemIndex) {
onGameLongClick(tile)
} else {
isFocusTriggeredScroll = true
centerItemIndex = index
coroutineScope.launch {
centerOnIndex(index, animate = true)
onGameLongClick(tile)
}
focusRequesters[index].requestFocus()
}
},
modifier = tileModifier,
imageUri = tile.iconUri,
useLargerFont = true,
)
}
}
}
}
}

View file

@ -0,0 +1,304 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import org.yuzu.yuzu_emu.R
import dev.eden.emu.ui.theme.EdenTheme
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun GameGrid(
gameTiles: List<GameTile>,
onGameClick: (GameTile) -> Unit,
onGameLongClick: (GameTile) -> Unit = {},
onNavigateToSettings: () -> Unit = {},
focusRequester: FocusRequester,
onNavigateUp: () -> Unit = {},
rowCount: Int = 2,
tileIconSize: Dp = 100.dp,
paddingTop: Dp = 0.dp,
modifier: Modifier = Modifier,
isLoading: Boolean = false,
emptyMessage: String? = null,
initialFocusedIndex: Int = 0,
onFocusedIndexChanged: (Int) -> Unit = {},
onShowGameInfo: (GameTile) -> Unit = {},
) {
Box(
modifier = modifier
.fillMaxSize()
) {
when {
isLoading -> LoadingMessage()
emptyMessage != null -> EmptyMessage(emptyMessage)
else -> {
GameGridContent(
gameTiles = gameTiles,
onGameClick = onGameClick,
onGameLongClick = onGameLongClick,
focusRequester = focusRequester,
onNavigateUp = onNavigateUp,
rowCount = rowCount,
tileIconSize = tileIconSize,
paddingTop = paddingTop,
initialFocusedIndex = initialFocusedIndex,
onFocusedIndexChanged = onFocusedIndexChanged,
onXPress = { index ->
gameTiles.getOrNull(index)?.let { tile ->
onShowGameInfo(tile)
}
},
)
}
}
}
}
@Composable
private fun GameGridContent(
gameTiles: List<GameTile>,
onGameClick: (GameTile) -> Unit,
onGameLongClick: (GameTile) -> Unit = {},
focusRequester: FocusRequester,
onNavigateUp: () -> Unit,
rowCount: Int,
tileIconSize: Dp,
paddingTop: Dp,
initialFocusedIndex: Int = 0,
onFocusedIndexChanged: (Int) -> Unit = {},
onXPress: (Int) -> Unit = {},
) {
val safeInitialIndex = initialFocusedIndex.coerceIn(0, (gameTiles.size - 1).coerceAtLeast(0))
var focusedIndex by remember { mutableIntStateOf(safeInitialIndex) }
val gridState = rememberLazyGridState(
initialFirstVisibleItemIndex = safeInitialIndex
)
LaunchedEffect(gameTiles.size) {
if (focusedIndex >= gameTiles.size) {
focusedIndex = 0
gridState.scrollToItem(0)
}
}
val tileFocusRequesters = remember(gameTiles.size) {
List(gameTiles.size) { FocusRequester() }
}
LaunchedEffect(focusRequester) {
if (tileFocusRequesters.isNotEmpty()) {
focusRequester.requestFocus()
}
}
LaunchedEffect(Unit) {
if (tileFocusRequesters.isNotEmpty() && safeInitialIndex in tileFocusRequesters.indices) {
gridState.scrollToItem(safeInitialIndex)
tileFocusRequesters[safeInitialIndex].requestFocus()
}
}
LaunchedEffect(focusedIndex) {
if (focusedIndex in tileFocusRequesters.indices) {
tileFocusRequesters[focusedIndex].requestFocus()
onFocusedIndexChanged(focusedIndex)
}
}
val outerPaddingTop = 62.dp
val outerPaddingBottom = 16.dp
val outerPaddingHorizontal = 0.dp
val gridVerticalSpacing = 0.dp
val gridVerticalPadding = 12.dp
val spacerHeight = 2.dp
val minTextHeight = 42.dp
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.padding(
top = outerPaddingTop,
start = outerPaddingHorizontal,
end = outerPaddingHorizontal,
bottom = outerPaddingBottom
)
.onPreviewKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown && gameTiles.isNotEmpty()) {
val currentRow = focusedIndex % rowCount
when (keyEvent.key) {
Key.ButtonX -> {
onXPress(focusedIndex)
true
}
Key.DirectionRight, Key.D -> {
val nextIndex = focusedIndex + rowCount
if (nextIndex < gameTiles.size) {
focusedIndex = nextIndex
}
true
}
Key.DirectionLeft, Key.A -> {
val prevIndex = focusedIndex - rowCount
if (prevIndex >= 0) {
focusedIndex = prevIndex
}
true
}
Key.DirectionDown, Key.S -> {
val nextIndex = focusedIndex + 1
if (nextIndex < gameTiles.size && nextIndex % rowCount != 0) {
focusedIndex = nextIndex
}
true
}
Key.DirectionUp, Key.W -> {
if (currentRow > 0) {
focusedIndex -= 1
true
} else {
onNavigateUp()
true
}
}
else -> false
}
} else {
false
}
},
contentAlignment = Alignment.TopStart
) {
val availableHeight = maxHeight
val heightForRows = (availableHeight - (gridVerticalPadding * 2) -
(gridVerticalSpacing * (rowCount - 1))).coerceAtLeast(0.dp)
val perRowHeight = heightForRows.safeDiv(rowCount)
val iconMaxSize = tileIconSize * 2
val iconSizeForGrid = (perRowHeight - spacerHeight - minTextHeight)
.coerceAtLeast(96.dp)
.coerceAtMost(iconMaxSize)
val tileHeight = perRowHeight.coerceAtLeast(iconSizeForGrid + spacerHeight + minTextHeight)
LazyHorizontalGrid(
rows = GridCells.Fixed(rowCount),
state = gridState,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = gridVerticalPadding),
) {
itemsIndexed(gameTiles, key = { _, tile -> tile.id }) { index, tile ->
val tileModifier = if (index == focusedIndex) {
Modifier
.focusRequester(focusRequester)
.focusRequester(tileFocusRequesters[index])
} else {
Modifier.focusRequester(tileFocusRequesters[index])
}
EdenTile(
title = tile.title,
iconSize = iconSizeForGrid,
tileHeight = tileHeight,
onClick = {
focusedIndex = index
onGameClick(tile)
},
onLongClick = {
focusedIndex = index
onGameLongClick(tile)
},
modifier = tileModifier,
imageUri = tile.iconUri,
)
}
}
}
}
@Composable
private fun LoadingMessage() {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.padding(bottom = 16.dp),
color = EdenTheme.colors.primary,
)
BasicText(
text = context.getString(R.string.loading),
style = EdenTheme.typography.body.copy(color = EdenTheme.colors.onBackground),
)
}
}
@Composable
private fun EmptyMessage(message: String) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
BasicText(
text = message,
style = EdenTheme.typography.body.copy(color = EdenTheme.colors.onBackground),
)
BasicText(
text = context.getString(R.string.manage_game_folders),
style = EdenTheme.typography.label.copy(color = EdenTheme.colors.onBackground),
modifier = Modifier.padding(top = 8.dp)
)
}
}
private fun Dp.safeDiv(divisor: Int): Dp = if (divisor > 0) this / divisor else 0.dp

View file

@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import org.yuzu.yuzu_emu.model.Game
data class GameTile(
val id: String,
val title: String,
val uri: String? = null,
val iconUri: String? = null,
val programId: Long = 0L,
val developer: String = "",
val version: String = "",
val isHomebrew: Boolean = false,
) {
companion object {
fun fromGame(game: Game): GameTile = GameTile(
id = game.path,
title = game.title,
uri = game.path,
iconUri = game.path,
programId = game.programId.toLongOrNull() ?: 0L,
developer = game.developer,
version = game.version,
isHomebrew = game.isHomebrew,
)
}
}

View file

@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.eden.emu.ui.theme.Dimens
import org.yuzu.yuzu_emu.R
enum class FooterButton(@DrawableRes val iconRes: Int) {
A(R.drawable.facebutton_a),
B(R.drawable.facebutton_b),
X(R.drawable.facebutton_x),
Y(R.drawable.facebutton_y),
}
@Composable
fun HomeFooter(
actions: List<FooterAction>,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = Dimens.paddingLg, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(Dimens.paddingXl),
verticalAlignment = Alignment.CenterVertically,
) {
actions.forEach { action ->
Row(
modifier = Modifier.padding(horizontal = Dimens.paddingXs, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = action.button.iconRes),
contentDescription = null,
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(Color.White.copy(alpha = 0.9f)),
)
BasicText(action.text, style = TextStyle(Color.White.copy(0.7f), 14.sp))
}
}
}
}
data class FooterAction(val button: FooterButton, val text: String)
data class FooterFocusRequesters(val primaryAction: FocusRequester, val secondaryAction: FocusRequester)

View file

@ -0,0 +1,186 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.Icon
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 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.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import dev.eden.emu.ui.theme.Dimens
import dev.eden.emu.ui.theme.EdenTheme
import dev.eden.emu.ui.theme.Shapes
import dev.eden.emu.ui.utils.ConfirmKeys
import java.io.File
@Composable
fun HomeHeader(
currentUser: String,
onUserClick: () -> Unit,
searchQuery: String,
onSearchQueryChange: (String) -> Unit,
isSearchExpanded: Boolean,
onSearchExpandedChange: (Boolean) -> Unit,
onSortClick: () -> Unit,
onFilterClick: () -> Unit,
onSettingsClick: () -> Unit,
focusRequesters: HeaderFocusRequesters,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var username by remember { mutableStateOf("Eden") }
var imagePath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(currentUser) {
val uuid = currentUser.ifEmpty { NativeLibrary.getCurrentUser() ?: "" }
if (uuid.isNotEmpty()) {
username = NativeLibrary.getUserUsername(uuid) ?: "Eden"
imagePath = NativeLibrary.getUserImagePath(uuid)
}
}
Row(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = Dimens.paddingXl, vertical = Dimens.paddingMd),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
UserProfileButton(username, imagePath, onUserClick, focusRequesters.userButton)
Row(
horizontalArrangement = Arrangement.spacedBy(Dimens.paddingLg),
verticalAlignment = Alignment.CenterVertically,
) {
ExpandableSearchBar(searchQuery, onSearchQueryChange, isSearchExpanded, onSearchExpandedChange, focusRequesters.searchButton)
HeaderIconButton(R.drawable.ic_sort, context.getString(R.string.home_sort), onSortClick, focusRequesters.sortButton)
HeaderIconButton(R.drawable.ic_filter, context.getString(R.string.home_layout), onFilterClick, focusRequesters.filterButton)
HeaderIconButton(R.drawable.ic_settings, context.getString(R.string.home_settings), onSettingsClick, focusRequesters.settingsButton)
}
}
}
@Composable
private fun UserProfileButton(
username: String,
imagePath: String?,
onClick: () -> Unit,
focusRequester: FocusRequester,
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val bitmap = remember(imagePath) {
imagePath?.takeIf { it.isNotEmpty() }?.let { path ->
File(path).takeIf { it.exists() }?.let { BitmapFactory.decodeFile(path) }
}
}
val defaultBitmap = remember {
NativeLibrary.getDefaultAccountBackupJpeg().let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
Row(
modifier = Modifier
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyDown && e.key in ConfirmKeys) { onClick(); true } else false
}
.clickable(interactionSource, null, onClick = onClick)
.background(if (isFocused) EdenTheme.colors.primary else EdenTheme.colors.surface, Shapes.medium)
.border(Dimens.borderWidth, if (isFocused) EdenTheme.colors.primary else EdenTheme.colors.surface, Shapes.medium)
.padding(horizontal = Dimens.paddingMd, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(Dimens.paddingMd),
verticalAlignment = Alignment.CenterVertically,
) {
(bitmap ?: defaultBitmap)?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(46.dp).clip(CircleShape)
)
}
BasicText(username, style = EdenTheme.typography.body.copy(fontSize = 18.sp, color = Color.White))
}
}
@Composable
fun HeaderIconButton(
iconRes: Int,
contentDescription: String,
onClick: () -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
Box(
modifier = modifier
.size(Dimens.iconLg)
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.onPreviewKeyEvent { e ->
if (e.type == KeyEventType.KeyDown && e.key in ConfirmKeys) { onClick(); true } else false
}
.clip(CircleShape)
.background(if (isFocused) EdenTheme.colors.primary else EdenTheme.colors.surface)
.clickable(interactionSource, null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = ImageVector.vectorResource(iconRes),
contentDescription = contentDescription,
tint = Color.White,
modifier = Modifier.size(Dimens.iconSm),
)
}
}
data class HeaderFocusRequesters(
val userButton: FocusRequester,
val searchButton: FocusRequester,
val sortButton: FocusRequester,
val filterButton: FocusRequester,
val settingsButton: FocusRequester,
)

View file

@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.yuzu.yuzu_emu.R
import dev.eden.emu.ui.theme.Dimens
import dev.eden.emu.ui.theme.EdenTheme
import dev.eden.emu.ui.theme.Shapes
import dev.eden.emu.ui.utils.DialogButton
enum class LayoutMode { TWO_ROW, CAROUSEL /*, ONE_ROW */ }
@Composable
fun LayoutSelectionDialog(
onDismiss: () -> Unit,
onSelectGrid: () -> Unit,
onSelectCarousel: () -> Unit,
) {
val context = LocalContext.current
val focusRequesters = remember { List(3) { FocusRequester() } }
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)) {
Column(
modifier = Modifier
.clip(Shapes.large)
.background(EdenTheme.colors.surface)
.padding(Dimens.paddingXl),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText(
text = context.getString(R.string.home_layout),
style = EdenTheme.typography.title.copy(color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
)
Spacer(Modifier.height(Dimens.paddingXl))
DialogButton(
text = context.getString(R.string.view_grid),
onClick = { onSelectGrid(); onDismiss() },
focusRequester = focusRequesters[0],
)
Spacer(Modifier.height(Dimens.paddingMd))
DialogButton(
text = context.getString(R.string.view_carousel),
onClick = { onSelectCarousel(); onDismiss() },
focusRequester = focusRequesters[1],
)
Spacer(Modifier.height(Dimens.paddingXl))
DialogButton(
text = context.getString(R.string.cancel),
onClick = onDismiss,
focusRequester = focusRequesters[2],
isSecondary = true,
)
}
}
}

View file

@ -0,0 +1,82 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.yuzu.yuzu_emu.R
import dev.eden.emu.ui.theme.Dimens
import dev.eden.emu.ui.theme.EdenTheme
import dev.eden.emu.ui.theme.Shapes
import dev.eden.emu.ui.utils.DialogButton
enum class SortMode { LAST_PLAYED, ALPHABETICAL }
@Composable
fun SortSelectionDialog(
currentSortMode: SortMode,
onDismiss: () -> Unit,
onSelectSort: (SortMode) -> Unit,
) {
val context = LocalContext.current
val focusRequesters = remember { List(3) { FocusRequester() } }
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true)) {
Column(
modifier = Modifier
.clip(Shapes.large)
.background(EdenTheme.colors.surface)
.padding(Dimens.paddingXl),
horizontalAlignment = Alignment.CenterHorizontally,
) {
BasicText(
text = context.getString(R.string.home_sort),
style = EdenTheme.typography.title.copy(color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
)
Spacer(Modifier.height(Dimens.paddingXl))
DialogButton(
text = context.getString(R.string.search_recently_played),
onClick = { onSelectSort(SortMode.LAST_PLAYED); onDismiss() },
focusRequester = focusRequesters[0],
isSelected = currentSortMode == SortMode.LAST_PLAYED,
)
Spacer(Modifier.height(Dimens.paddingMd))
DialogButton(
text = context.getString(R.string.alphabetical),
onClick = { onSelectSort(SortMode.ALPHABETICAL); onDismiss() },
focusRequester = focusRequesters[1],
isSelected = currentSortMode == SortMode.ALPHABETICAL,
)
Spacer(Modifier.height(Dimens.paddingXl))
DialogButton(
text = context.getString(R.string.cancel),
onClick = onDismiss,
focusRequester = focusRequesters[2],
isSecondary = true,
)
}
}
}

View file

@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.navigation
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.*
import dev.eden.emu.ui.components.HeaderFocusRequesters
import dev.eden.emu.ui.utils.NavKeys
/** Navigation zones for the home screen */
enum class NavigationZone { HEADER, CONTENT/*, FOOTER */ }
/**
* Manages focus and navigation between Header and Content zones.
* Uses MutableState for reactive UI updates.
* (Default Focus Manager breaks and is based on "placement", not suitable)
*/
class HomeNavigationManager(
private val headerFocusRequesters: HeaderFocusRequesters,
private val contentFocusRequester: FocusRequester,
) {
private val _currentZone: MutableState<NavigationZone> = mutableStateOf(NavigationZone.CONTENT)
val currentZone: NavigationZone get() = _currentZone.value
private val _headerIndex: MutableState<Int> = mutableIntStateOf(0)
val headerIndex: Int get() = _headerIndex.value
// Expose state for Compose observation
val currentZoneState: MutableState<NavigationZone> get() = _currentZone
val headerIndexState: MutableState<Int> get() = _headerIndex
private val headerElements = listOf(
headerFocusRequesters.userButton,
headerFocusRequesters.searchButton,
headerFocusRequesters.sortButton,
headerFocusRequesters.filterButton,
headerFocusRequesters.settingsButton,
)
fun navigateUp() {
if (_currentZone.value == NavigationZone.CONTENT) {
_currentZone.value = NavigationZone.HEADER
headerElements.getOrNull(_headerIndex.value)?.requestFocus()
}
}
fun navigateDown() {
if (_currentZone.value == NavigationZone.HEADER) {
_currentZone.value = NavigationZone.CONTENT
contentFocusRequester.requestFocus()
}
}
fun navigateLeft() {
if (_currentZone.value == NavigationZone.HEADER && _headerIndex.value > 0) {
_headerIndex.value--
headerElements.getOrNull(_headerIndex.value)?.requestFocus()
}
}
fun navigateRight() {
if (_currentZone.value == NavigationZone.HEADER && _headerIndex.value < headerElements.lastIndex) {
_headerIndex.value++
headerElements.getOrNull(_headerIndex.value)?.requestFocus()
}
}
fun requestInitialFocus() {
_currentZone.value = NavigationZone.CONTENT
contentFocusRequester.requestFocus()
}
fun resetToContent() {
_currentZone.value = NavigationZone.CONTENT
}
}
/** Handle D-Pad navigation for home screen */
fun handleHomeNavigation(keyEvent: KeyEvent, nav: HomeNavigationManager): Boolean {
if (keyEvent.type != KeyEventType.KeyDown) return false
return when (keyEvent.key) {
in NavKeys.up -> { nav.navigateUp(); true }
in NavKeys.down -> { nav.navigateDown(); true }
in NavKeys.left -> if (nav.currentZone == NavigationZone.HEADER) { nav.navigateLeft(); true } else false
in NavKeys.right -> if (nav.currentZone == NavigationZone.HEADER) { nav.navigateRight(); true } else false
else -> false
}
}

View file

@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.theme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
private val LocalEdenTypography = staticCompositionLocalOf {
EdenTypography(
title = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
),
body = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
),
label = TextStyle(
fontFamily = FontFamily.SansSerif,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
),
)
}
object EdenTheme {
val colors: EdenColors
@Composable get() = LocalEdenColors.current
val typography: EdenTypography
@Composable get() = LocalEdenTypography.current
}
@Composable
fun EdenTheme(
colors: EdenColors = LocalEdenColors.current,
typography: EdenTypography = LocalEdenTypography.current,
content: @Composable () -> Unit,
) {
CompositionLocalProvider(
LocalEdenColors provides colors,
LocalEdenTypography provides typography,
content = content,
)
}
data class EdenTypography(
val title: TextStyle,
val body: TextStyle,
val label: TextStyle,
)

View file

@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.theme
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
val EdenBrandColor = Color(0xFFA161F3)
val EdenSplashColor = EdenBrandColor
val EdenGridLine = Color(0x2AA161F3)
val EdenBackground = Color(0xFF191521)
val EdenSurface = Color(0xFF221C2B)
val EdenSurfaceVariant = Color(0xFF2D2538)
val EdenOnBackground = Color(0xFFEAE5F2)
val EdenOnSurface = Color(0xFFEAE5F2)
val EdenOnSurfaceVariant = Color(0xFFB0A8BA)
val EdenOnPrimary = Color(0xFFFFFFFF)
internal val LocalEdenColors = staticCompositionLocalOf {
EdenColors(
background = EdenBackground,
surface = EdenSurface,
surfaceVariant = EdenSurfaceVariant,
primary = EdenBrandColor,
onBackground = EdenOnBackground,
onSurface = EdenOnSurface,
onSurfaceVariant = EdenOnSurfaceVariant,
onPrimary = EdenOnPrimary,
)
}
data class EdenColors(
val background: Color,
val surface: Color,
val surfaceVariant: Color,
val primary: Color,
val onBackground: Color,
val onSurface: Color,
val onSurfaceVariant: Color,
val onPrimary: Color,
)

View file

@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
// Grid background
val EdenGridSpacing = 58.dp
val EdenGridLineWidth = 2.dp
const val EdenGridSpeedDpPerSecond = 12f
// Common dimensions
object Dimens {
// Padding
val paddingXs = 4.dp
val paddingSm = 8.dp
val paddingMd = 12.dp
val paddingLg = 16.dp
val paddingXl = 24.dp
// Icon sizes
val iconSm = 24.dp
val iconMd = 32.dp
val iconLg = 48.dp
// Border
val borderWidth = 2.dp
// Corner radius
val radiusSm = 8.dp
val radiusMd = 12.dp
val radiusLg = 16.dp
}
// Common shapes
object Shapes {
val small = RoundedCornerShape(Dimens.radiusSm)
val medium = RoundedCornerShape(Dimens.radiusMd)
val large = RoundedCornerShape(Dimens.radiusLg)
}

View file

@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.utils
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
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.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.eden.emu.ui.theme.Dimens
import dev.eden.emu.ui.theme.EdenTheme
import dev.eden.emu.ui.theme.Shapes
/**
* New dialog button for consistent styling across dialogs
*/
@Composable
fun DialogButton(
text: String,
onClick: () -> Unit,
focusRequester: FocusRequester,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
isSecondary: Boolean = false,
) {
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val backgroundColor = when {
isFocused -> EdenTheme.colors.primary
isSelected -> EdenTheme.colors.primary.copy(alpha = 0.3f)
isSecondary -> Color.Transparent
else -> EdenTheme.colors.background
}
val borderColor = when {
isFocused || isSelected -> EdenTheme.colors.primary
isSecondary -> EdenTheme.colors.onBackground.copy(alpha = 0.3f)
else -> EdenTheme.colors.onBackground.copy(alpha = 0.2f)
}
Box(
modifier = modifier
.fillMaxWidth()
.clip(Shapes.medium)
.background(backgroundColor)
.border(Dimens.borderWidth, borderColor, Shapes.medium)
.focusRequester(focusRequester)
.focusable(interactionSource = interactionSource)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyUp && event.key in confirmKeys) {
onClick()
true
} else false
}
.clickable(interactionSource, null, onClick = onClick)
.padding(vertical = 14.dp, horizontal = 20.dp),
contentAlignment = Alignment.Center,
) {
BasicText(
text = if (isSelected && !isSecondary) "✓ $text" else text,
style = EdenTheme.typography.body.copy(color = Color.White, fontSize = 16.sp)
)
}
}
private val confirmKeys = setOf(Key.Enter, Key.DirectionCenter, Key.ButtonA)

View file

@ -0,0 +1,17 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package dev.eden.emu.ui.utils
import androidx.compose.ui.input.key.Key
val ConfirmKeys = setOf(Key.Enter, Key.DirectionCenter, Key.ButtonA)
val MenuKeys = setOf(Key.ButtonX, Key.Menu)
val BackKeys = setOf(Key.Escape, Key.Back, Key.ButtonB)
object NavKeys {
val up = setOf(Key.DirectionUp, Key.W)
val down = setOf(Key.DirectionDown, Key.S)
val left = setOf(Key.DirectionLeft, Key.A)
val right = setOf(Key.DirectionRight, Key.D)
}

View file

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
@ -18,6 +18,8 @@ import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
import android.content.res.Configuration
import android.os.LocaleList
import coil.ImageLoader
import coil.ImageLoaderFactory
import org.yuzu.yuzu_emu.features.settings.model.IntSetting
import org.yuzu.yuzu_emu.utils.DirectoryInitialization
import org.yuzu.yuzu_emu.utils.DocumentsTree
@ -28,7 +30,7 @@ import java.util.Locale
fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir
class YuzuApplication : Application() {
class YuzuApplication : Application(), ImageLoaderFactory {
private fun createNotificationChannels() {
val name: CharSequence = getString(R.string.app_notification_channel_name)
val description = getString(R.string.app_notification_channel_description)
@ -76,6 +78,12 @@ class YuzuApplication : Application() {
createNotificationChannels()
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.crossfade(true)
.build()
}
companion object {
var documentsTree: DocumentsTree? = null
lateinit var application: YuzuApplication

View file

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2023 yuzu Emulator Project
@ -649,7 +649,7 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener, InputManager
fun launch(activity: AppCompatActivity, game: Game) {
val launcher = Intent(activity, EmulationActivity::class.java)
launcher.putExtra(EXTRA_SELECTED_GAME, game)
launcher.putExtra("game", game) // Use "game" to match navigation argus
activity.startActivity(launcher)
}

Some files were not shown because too many files have changed in this diff Show more