Skip to content

Commit 70e2721

Browse files
authored
feat: Custom output path + better GUI scrollbars (#127)
1 parent c98913e commit 70e2721

9 files changed

Lines changed: 269 additions & 37 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2026 Morphe.
3+
* https://github.com/MorpheApp/morphe-cli
4+
*/
5+
6+
package app.morphe.gui.ui.components
7+
8+
import androidx.compose.foundation.ScrollbarStyle
9+
import androidx.compose.foundation.shape.RoundedCornerShape
10+
import androidx.compose.ui.graphics.RectangleShape
11+
import androidx.compose.runtime.Composable
12+
import androidx.compose.ui.unit.Dp
13+
import androidx.compose.ui.unit.dp
14+
import app.morphe.gui.ui.theme.LocalMorpheAccents
15+
import app.morphe.gui.ui.theme.LocalMorpheCorners
16+
17+
/**
18+
* Shared scrollbar style. Always-visible accent thumb, corners pulled from
19+
* the active Morphe theme so the scrollbar matches the rest of the geometry.
20+
*/
21+
@Composable
22+
fun morpheScrollbarStyle(
23+
thickness: Dp = 6.dp,
24+
minimalHeight: Dp = 24.dp,
25+
idleAlpha: Float = 0.55f
26+
): ScrollbarStyle {
27+
val accent = LocalMorpheAccents.current.primary
28+
val corners = LocalMorpheCorners.current
29+
return ScrollbarStyle(
30+
minimalHeight = minimalHeight,
31+
thickness = thickness,
32+
shape = if (corners.small >= 8.dp) RoundedCornerShape(corners.small) else RectangleShape,
33+
hoverDurationMillis = 0,
34+
unhoverColor = accent.copy(alpha = idleAlpha),
35+
hoverColor = accent
36+
)
37+
}

src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ fun SettingsButton(
5858

5959
var showSettingsDialog by remember { mutableStateOf(false) }
6060
var autoCleanupTempFiles by remember { mutableStateOf(true) }
61+
var defaultOutputDirectory by remember { mutableStateOf<String?>(null) }
6162
var patchSources by remember { mutableStateOf<List<PatchSource>>(emptyList()) }
6263
var activePatchSourceId by remember { mutableStateOf("") }
6364
var keystorePath by remember { mutableStateOf<String?>(null) }
@@ -71,6 +72,7 @@ fun SettingsButton(
7172
if (showSettingsDialog) {
7273
val config = configRepository.loadConfig()
7374
autoCleanupTempFiles = config.autoCleanupTempFiles
75+
defaultOutputDirectory = config.defaultOutputDirectory
7476
patchSources = config.patchSource
7577
activePatchSourceId = config.activePatchSourceId
7678
keystorePath = config.keystorePath
@@ -119,6 +121,11 @@ fun SettingsButton(
119121
configRepository.setAutoCleanupTempFiles(enabled)
120122
}
121123
},
124+
defaultOutputDirectory = defaultOutputDirectory,
125+
onDefaultOutputDirectoryChange = { path ->
126+
defaultOutputDirectory = path
127+
scope.launch { configRepository.setDefaultOutputDirectory(path) }
128+
},
122129
useExpertMode = !modeState.isSimplified,
123130
onExpertModeChange = { enabled ->
124131
modeState.onChange(!enabled)

src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import java.awt.Desktop
4747
import java.awt.FileDialog
4848
import java.awt.Frame
4949
import java.io.File
50+
import javax.swing.JFileChooser
5051
import java.security.KeyStore
5152
import java.security.MessageDigest
5253
import java.security.cert.X509Certificate
@@ -83,6 +84,8 @@ fun SettingsDialog(
8384
onThemeChange: (ThemePreference) -> Unit,
8485
autoCleanupTempFiles: Boolean,
8586
onAutoCleanupChange: (Boolean) -> Unit,
87+
defaultOutputDirectory: String?,
88+
onDefaultOutputDirectoryChange: (String?) -> Unit,
8689
useExpertMode: Boolean,
8790
onExpertModeChange: (Boolean) -> Unit,
8891
onDismiss: () -> Unit,
@@ -221,6 +224,17 @@ fun SettingsDialog(
221224

222225
SettingsDivider(borderColor)
223226

227+
// ── Output Folder ──
228+
OutputFolderSection(
229+
defaultOutputDirectory = defaultOutputDirectory,
230+
onDefaultOutputDirectoryChange = onDefaultOutputDirectoryChange,
231+
mono = mono,
232+
borderColor = borderColor,
233+
enabled = !isPatching
234+
)
235+
236+
SettingsDivider(borderColor)
237+
224238
// ── Signing / Keystore ──
225239
SigningSection(
226240
keystorePath = keystorePath,
@@ -616,7 +630,8 @@ private fun LicensesDialog(onDismiss: () -> Unit) {
616630
.align(Alignment.CenterEnd)
617631
.fillMaxHeight()
618632
.padding(vertical = 6.dp),
619-
adapter = rememberScrollbarAdapter(listState)
633+
adapter = rememberScrollbarAdapter(listState),
634+
style = morpheScrollbarStyle()
620635
)
621636
}
622637
}
@@ -1104,7 +1119,8 @@ private fun LicenseTextDialog(license: License, onDismiss: () -> Unit) {
11041119
.align(Alignment.CenterEnd)
11051120
.fillMaxHeight()
11061121
.padding(vertical = 6.dp),
1107-
adapter = rememberScrollbarAdapter(scrollState)
1122+
adapter = rememberScrollbarAdapter(scrollState),
1123+
style = morpheScrollbarStyle()
11081124
)
11091125
} else {
11101126
Column(
@@ -1315,6 +1331,126 @@ private fun SettingToggleRow(
13151331
}
13161332
}
13171333

1334+
@Composable
1335+
private fun OutputFolderSection(
1336+
defaultOutputDirectory: String?,
1337+
onDefaultOutputDirectoryChange: (String?) -> Unit,
1338+
mono: androidx.compose.ui.text.font.FontFamily,
1339+
borderColor: Color,
1340+
enabled: Boolean = true
1341+
) {
1342+
val corners = LocalMorpheCorners.current
1343+
val alpha = if (enabled) 1f else 0.4f
1344+
val outputDir = defaultOutputDirectory?.let { File(it) }
1345+
val outputDirExists = outputDir?.isDirectory == true
1346+
1347+
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
1348+
SectionLabel("OUTPUT FOLDER", mono)
1349+
Spacer(Modifier.height(6.dp))
1350+
1351+
Text(
1352+
text = if (!enabled) "Disabled while patching"
1353+
else "Where patched APKs are saved. A per-app subfolder is created inside.",
1354+
fontSize = 11.sp,
1355+
fontFamily = mono,
1356+
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f * alpha)
1357+
)
1358+
1359+
Spacer(Modifier.height(8.dp))
1360+
1361+
Row(
1362+
modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
1363+
verticalAlignment = Alignment.CenterVertically,
1364+
horizontalArrangement = Arrangement.spacedBy(6.dp)
1365+
) {
1366+
Box(
1367+
modifier = Modifier
1368+
.weight(1f)
1369+
.fillMaxHeight()
1370+
.clip(RoundedCornerShape(corners.small))
1371+
.border(1.dp, borderColor, RoundedCornerShape(corners.small))
1372+
.padding(horizontal = 10.dp),
1373+
contentAlignment = Alignment.CenterStart
1374+
) {
1375+
Text(
1376+
text = outputDir?.name ?: "APK's folder (default)",
1377+
fontSize = 11.sp,
1378+
fontFamily = mono,
1379+
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f * alpha),
1380+
maxLines = 1,
1381+
overflow = TextOverflow.Ellipsis
1382+
)
1383+
}
1384+
1385+
OutlinedButton(
1386+
onClick = {
1387+
val chooser = JFileChooser().apply {
1388+
dialogTitle = "Select Output Folder"
1389+
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
1390+
isAcceptAllFileFilterUsed = false
1391+
outputDir?.takeIf { it.isDirectory }?.let { currentDirectory = it }
1392+
}
1393+
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
1394+
onDefaultOutputDirectoryChange(chooser.selectedFile.absolutePath)
1395+
}
1396+
},
1397+
enabled = enabled,
1398+
shape = RoundedCornerShape(corners.small),
1399+
border = BorderStroke(1.dp, borderColor),
1400+
contentPadding = PaddingValues(horizontal = 10.dp),
1401+
modifier = Modifier.fillMaxHeight()
1402+
) {
1403+
Text(
1404+
"BROWSE",
1405+
fontFamily = mono,
1406+
fontWeight = FontWeight.SemiBold,
1407+
fontSize = 9.sp,
1408+
letterSpacing = 0.5.sp
1409+
)
1410+
}
1411+
1412+
if (defaultOutputDirectory != null) {
1413+
OutlinedButton(
1414+
onClick = { onDefaultOutputDirectoryChange(null) },
1415+
enabled = enabled,
1416+
shape = RoundedCornerShape(corners.small),
1417+
border = BorderStroke(1.dp, borderColor),
1418+
contentPadding = PaddingValues(horizontal = 10.dp),
1419+
modifier = Modifier.fillMaxHeight()
1420+
) {
1421+
Text(
1422+
"RESET",
1423+
fontFamily = mono,
1424+
fontWeight = FontWeight.SemiBold,
1425+
fontSize = 9.sp,
1426+
letterSpacing = 0.5.sp
1427+
)
1428+
}
1429+
}
1430+
}
1431+
1432+
if (defaultOutputDirectory != null && !outputDirExists) {
1433+
Text(
1434+
text = "Folder not found — will be created on next patch",
1435+
fontSize = 10.sp,
1436+
fontFamily = mono,
1437+
color = Color(0xFFE0A030)
1438+
)
1439+
}
1440+
1441+
if (defaultOutputDirectory != null) {
1442+
Text(
1443+
text = defaultOutputDirectory,
1444+
fontSize = 9.sp,
1445+
fontFamily = mono,
1446+
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f),
1447+
maxLines = 1,
1448+
overflow = TextOverflow.Ellipsis
1449+
)
1450+
}
1451+
}
1452+
}
1453+
13181454
@Composable
13191455
private fun ActionButton(
13201456
label: String,

src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState
2020
import androidx.compose.foundation.interaction.collectIsHoveredAsState
2121
import androidx.compose.foundation.text.BasicTextField
2222
import androidx.compose.foundation.layout.*
23+
import androidx.compose.foundation.HorizontalScrollbar
2324
import androidx.compose.foundation.horizontalScroll
25+
import androidx.compose.foundation.rememberScrollbarAdapter
2426
import androidx.compose.ui.text.style.TextOverflow
2527
import androidx.compose.foundation.rememberScrollState
2628
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -63,6 +65,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator
6365
import cafe.adriel.voyager.navigator.currentOrThrow
6466
import app.morphe.gui.data.model.SupportedApp
6567
import app.morphe.gui.ui.components.TopBarRow
68+
import app.morphe.gui.ui.components.morpheScrollbarStyle
6669
import app.morphe.gui.ui.screens.home.components.ApkInfoCard
6770
import app.morphe.gui.ui.screens.home.components.FullScreenDropZone
6871
import app.morphe.gui.ui.components.OfflineBanner
@@ -1402,20 +1405,33 @@ private fun SupportedAppsMasterDetail(
14021405
val parentWidth = maxWidth
14031406
val scrollState = rememberScrollState()
14041407

1405-
Row(
1406-
modifier = Modifier
1407-
.horizontalScroll(scrollState)
1408-
.widthIn(min = parentWidth)
1409-
.padding(horizontal = 8.dp, vertical = 4.dp),
1410-
horizontalArrangement = Arrangement.spacedBy(cardSpacing, Alignment.CenterHorizontally),
1411-
verticalAlignment = Alignment.Top
1412-
) {
1413-
apps.forEach { app ->
1414-
SupportedAppVerticalCard(
1415-
app = app,
1416-
isSelected = app.packageName == selectedApp?.packageName,
1417-
onClick = { onSelect(app) },
1418-
isDefaultSource = isDefaultSource
1408+
Column(modifier = Modifier.fillMaxWidth()) {
1409+
Row(
1410+
modifier = Modifier
1411+
.horizontalScroll(scrollState)
1412+
.widthIn(min = parentWidth)
1413+
.padding(horizontal = 8.dp, vertical = 4.dp),
1414+
horizontalArrangement = Arrangement.spacedBy(cardSpacing, Alignment.CenterHorizontally),
1415+
verticalAlignment = Alignment.Top
1416+
) {
1417+
apps.forEach { app ->
1418+
SupportedAppVerticalCard(
1419+
app = app,
1420+
isSelected = app.packageName == selectedApp?.packageName,
1421+
onClick = { onSelect(app) },
1422+
isDefaultSource = isDefaultSource
1423+
)
1424+
}
1425+
}
1426+
1427+
if (scrollState.maxValue > 0) {
1428+
Spacer(Modifier.height(6.dp))
1429+
HorizontalScrollbar(
1430+
adapter = rememberScrollbarAdapter(scrollState),
1431+
modifier = Modifier
1432+
.fillMaxWidth()
1433+
.padding(horizontal = 8.dp),
1434+
style = morpheScrollbarStyle()
14191435
)
14201436
}
14211437
}

src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import app.morphe.gui.ui.components.ErrorDialog
5454
import app.morphe.gui.ui.components.DeviceIndicator
5555
import app.morphe.gui.ui.components.MorpheSwitch
5656
import app.morphe.gui.ui.components.SettingsButton
57+
import app.morphe.gui.ui.components.morpheScrollbarStyle
5758
import app.morphe.gui.ui.components.getErrorType
5859
import app.morphe.gui.ui.components.getFriendlyErrorMessage
5960
import app.morphe.gui.ui.screens.patching.PatchingScreen
@@ -481,7 +482,8 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) {
481482

482483
androidx.compose.foundation.VerticalScrollbar(
483484
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
484-
adapter = androidx.compose.foundation.rememberScrollbarAdapter(lazyListState)
485+
adapter = androidx.compose.foundation.rememberScrollbarAdapter(lazyListState),
486+
style = morpheScrollbarStyle()
485487
)
486488
}
487489

src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ class PatchSelectionViewModel(
3838
// Actual path to use - may differ from patchesFilePath if we had to re-download
3939
private var actualPatchesFilePath: String = patchesFilePath
4040

41+
// User-configured output folder; null means save next to the input APK.
42+
private var defaultOutputDirectory: String? = null
43+
4144
private val _uiState = MutableStateFlow(PatchSelectionUiState(
4245
apkArchitectures = apkArchitectures,
4346
stripLibsStatus = computeStripLibsStatus(apkArchitectures, ANDROID_ARCHITECTURES)
@@ -52,6 +55,7 @@ class PatchSelectionViewModel(
5255
private fun loadStripLibsPreference() {
5356
screenModelScope.launch {
5457
val config = configRepository.loadConfig()
58+
defaultOutputDirectory = config.defaultOutputDirectory
5559
_uiState.value = _uiState.value.copy(
5660
stripLibsStatus = computeStripLibsStatus(apkArchitectures, config.keepArchitectures)
5761
)
@@ -210,10 +214,10 @@ class PatchSelectionViewModel(
210214
}
211215

212216
fun createPatchConfig(continueOnError: Boolean = false): PatchConfig {
213-
// Create app folder in the same location as the input APK
214217
val inputFile = File(apkPath)
215218
val appFolderName = apkName.replace(" ", "-")
216-
val outputDir = File(inputFile.parentFile, appFolderName)
219+
val baseOutputDir = defaultOutputDirectory?.let { File(it) } ?: inputFile.parentFile
220+
val outputDir = File(baseOutputDir, appFolderName)
217221
outputDir.mkdirs()
218222

219223
// Extract version from APK filename and patches version for output name

0 commit comments

Comments
 (0)