Desktop: replace tiny Swing folder chooser with in-app themed picker

JFileChooser rendered unusably small on this HiDPI/Wayland compositor and
had no resize grip. Replace it with a Compose dialog matching the app's
navy+teal skin: breadcrumb row (Home + path steps), scrollable list of
subfolders as cards (dot-dirs hidden), '..' row, and a confirm button
naming the selected folder. Scales with the app's Ctrl+-/= zoom since it
lives inside the Compose tree. Pure nav logic in FolderPicker with 3 new
tests (34 total green).
This commit is contained in:
avi 2026-09-15 06:41:05 -05:00
commit b45e9ff216
2 changed files with 143 additions and 11 deletions

View file

@ -26,6 +26,7 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -43,7 +44,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import java.io.File import java.io.File
import javax.swing.JFileChooser
// ---- engine ----------------------------------------------------------------- // ---- engine -----------------------------------------------------------------
@ -83,16 +83,106 @@ fun EngineScreen(state: DesktopState) {
// ---- library --------------------------------------------------------------- // ---- library ---------------------------------------------------------------
private fun chooseFolder(): File? { // ---- folder picker (in-app, themed, zoom-aware) -----------------------------
val chooser = JFileChooser().apply {
dialogTitle = "Choose your recordings folder" /**
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY * Pure folder-picker navigation (unit-tested, no UI).
isAcceptAllFileFilterUsed = false * Children = non-hidden directories, sorted; parent walks the path back.
// Without an explicit size the dialog comes up tiny on HiDPI */
// compositors (and there is no resize handle), so pin a usable one. object FolderPicker {
preferredSize = java.awt.Dimension(1040, 700) fun children(dir: File): List<File> =
dir.listFiles()?.filter {
it.isDirectory && !it.name.startsWith(".")
}?.sortedBy { it.name.lowercase() }.orEmpty()
/** Directory list starting at home, walking into [dir]'s chain if set. */
fun pathFromHome(dir: File?, home: File): List<File> {
val start = dir ?: return listOf(home)
val chain = ArrayDeque<File>()
var cur: File? = start
while (cur != null) {
chain.addFirst(cur)
if (cur.absolutePath == home.absolutePath) break
cur = cur.parentFile
} }
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null // If start isn't under home (external volume), show its own chain.
return chain.toList().ifEmpty { listOf(start) }
}
}
@androidx.compose.runtime.Composable
fun FolderPickerDialog(
startDir: File?,
onResult: (File?) -> Unit,
) {
val home = remember { File(System.getProperty("user.home")) }
var current by remember {
mutableStateOf(startDir?.takeIf { it.isDirectory } ?: home)
}
var error by remember { mutableStateOf<String?>(null) }
val subs = remember(current) { FolderPicker.children(current) }
val crumbs = remember(current) { FolderPicker.pathFromHome(current, home) }
AlertDialog(
onDismissRequest = { onResult(null) },
title = { Text("Choose your recordings folder") },
text = {
Column(Modifier.fillMaxWidth().heightIn(min = 320.dp, max = 460.dp),
verticalArrangement = Arrangement.spacedBy(6.dp)) {
// Breadcrumb row: home + each step; click to jump.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton({ current = home }) { Text("Home") }
crumbs.dropWhile { it.absolutePath == home.absolutePath }
.forEach { d ->
Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton({ current = d }) {
Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1)
}
}
}
Text(current.absolutePath,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
HorizontalDivider()
if (current.parentFile != null) {
TextButton(
{ current.parentFile?.let { current = it } },
modifier = Modifier.fillMaxWidth(),
) { Text("⋱ .. (go up)") }
}
if (subs.isEmpty()) {
Text("No subfolders here.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
LazyColumn(Modifier.weight(1f)) {
items(subs, key = { it.absolutePath }) { d ->
Card(Modifier.fillMaxWidth().clickable { current = d }) {
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp,
vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
Text("▸", color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text(d.name, style = MaterialTheme.typography.bodyMedium)
}
}
Spacer(Modifier.height(4.dp))
}
}
error?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
}
},
confirmButton = {
TextButton({
if (current.isDirectory) onResult(current)
else error = "That folder no longer exists."
}) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") }
},
dismissButton = { TextButton({ onResult(null) }) { Text("Cancel") } },
)
} }
@androidx.compose.runtime.Composable @androidx.compose.runtime.Composable
@ -103,6 +193,7 @@ fun LibraryScreen(state: DesktopState) {
val hits by state.searchResults.collectAsState() val hits by state.searchResults.collectAsState()
val connected by state.connected.collectAsState() val connected by state.connected.collectAsState()
var renaming by remember { mutableStateOf<File?>(null) } var renaming by remember { mutableStateOf<File?>(null) }
var showPicker by remember { mutableStateOf(false) }
var deleting by remember { mutableStateOf<File?>(null) } var deleting by remember { mutableStateOf<File?>(null) }
var deletingMany by remember { mutableStateOf(false) } var deletingMany by remember { mutableStateOf(false) }
var deleteNotice by remember { mutableStateOf<String?>(null) } var deleteNotice by remember { mutableStateOf<String?>(null) }
@ -136,7 +227,7 @@ fun LibraryScreen(state: DesktopState) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) { OutlinedButton({ showPicker = true }) {
Text("Choose folder…") Text("Choose folder…")
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@ -300,6 +391,16 @@ fun LibraryScreen(state: DesktopState) {
} }
} }
if (showPicker) {
FolderPickerDialog(
startDir = folder ?: File(System.getProperty("user.home")),
onResult = { picked ->
showPicker = false
picked?.let { state.pickFolder(it) }
},
)
}
renaming?.let { target -> renaming?.let { target ->
var text by remember(target) { mutableStateOf(target.nameWithoutExtension) } var text by remember(target) { mutableStateOf(target.nameWithoutExtension) }
var error by remember(target) { mutableStateOf<String?>(null) } var error by remember(target) { mutableStateOf<String?>(null) }

View file

@ -94,3 +94,34 @@ class LibraryQueueTest {
assertEquals(listOf("fresh.m4a"), picked.map { it.name }) assertEquals(listOf("fresh.m4a"), picked.map { it.name })
} }
} }
class FolderPickerTest {
@Test fun childrenListsOnlyVisibleDirsSorted() {
val dir = createTempDirectory("fp").toFile()
val b = File(dir, "Bee").apply { mkdir() }
val a = File(dir, "apple").apply { mkdir() }
File(dir, ".cache").apply { mkdir() }
File(dir, "file.txt").writeText("x")
assertEquals(listOf(a, b), FolderPicker.children(dir))
}
@Test fun pathFromHomeWalksChainUnderHome() {
val home = File("/home/x")
val deep = File("/home/x/Music/Recordings")
assertEquals(
listOf(File("/home/x"), File("/home/x/Music"), deep),
FolderPicker.pathFromHome(deep, home),
)
assertEquals(listOf(home), FolderPicker.pathFromHome(null, home))
assertEquals(listOf(home), FolderPicker.pathFromHome(home, home))
}
@Test fun pathFromHomeHandlesExternalVolume() {
val home = File("/home/x")
val ext = File("/mnt/vids/clips")
// Not under home: its own chain, rooted at '/', no crash.
val chain = FolderPicker.pathFromHome(ext, home)
assertEquals(ext, chain.last())
assertTrue(chain.size >= 3)
}
}