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:
parent
3e0e3b50cb
commit
b45e9ff216
2 changed files with 143 additions and 11 deletions
|
|
@ -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) }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue